feat(dashboard): add full-set pipeline summary with single endpoint
Add GET /api/dashboard/summary aggregating pipeline status, category distribution, publish summary, and outperformers in one call. Rewrite dashboard.html with 5 KPI cards, pipeline funnel, publish status, and category/source-format breakdowns (CSS bars, no chart lib). Backend: ChannelVideoRepository counts (shorts/uncategorized), PublishPackageRepository.countByStatus, pipelineStats shorts/longForm, CategoryService.distribution, PublishService.dashboardSummary, new DashboardService + DashboardApiController. Fix: PublishService.list(null) hit UnsupportedOperationException because findAll(Sort) uses Criteria, which rejects nullsLast precedence. Route the no-status path through a @Query method so Sort is appended as HQL ORDER BY (supports NULLS LAST). Also fixes the latent bug in /api/v1/publish all-list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
92ae575646
commit
2ec3915789
@ -63,4 +63,21 @@ public class CategoryService {
|
|||||||
public long countVideos(Long categoryId) {
|
public long countVideos(Long categoryId) {
|
||||||
return channelVideoRepository.countByCategoryId(categoryId);
|
return channelVideoRepository.countByCategoryId(categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 대시보드용 카테고리 분포: 카테고리별 영상 수 + 미분류 수. */
|
||||||
|
public java.util.Map<String, Object> distribution() {
|
||||||
|
java.util.List<java.util.Map<String, Object>> cats = new java.util.ArrayList<>();
|
||||||
|
for (Category c : getAll()) {
|
||||||
|
java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
|
||||||
|
m.put("id", c.getId());
|
||||||
|
m.put("name", c.getName());
|
||||||
|
m.put("color", c.getColor());
|
||||||
|
m.put("count", channelVideoRepository.countByCategoryId(c.getId()));
|
||||||
|
cats.add(m);
|
||||||
|
}
|
||||||
|
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||||
|
result.put("categories", cats);
|
||||||
|
result.put("uncategorized", channelVideoRepository.countByCategoryIdIsNull());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -190,7 +190,12 @@ public class ChannelVideoCurationService {
|
|||||||
/** 대시보드/칸반용 파이프라인 통계: 총계 + 상태별/출처별 분포. */
|
/** 대시보드/칸반용 파이프라인 통계: 총계 + 상태별/출처별 분포. */
|
||||||
public java.util.Map<String, Object> pipelineStats() {
|
public java.util.Map<String, Object> pipelineStats() {
|
||||||
java.util.Map<String, Object> stats = new java.util.LinkedHashMap<>();
|
java.util.Map<String, Object> stats = new java.util.LinkedHashMap<>();
|
||||||
stats.put("total", channelVideoRepository.count());
|
long total = channelVideoRepository.count();
|
||||||
|
stats.put("total", total);
|
||||||
|
|
||||||
|
long shorts = channelVideoRepository.countByIsShortsTrue();
|
||||||
|
stats.put("shorts", shorts);
|
||||||
|
stats.put("longForm", Math.max(0, total - shorts));
|
||||||
|
|
||||||
java.util.Map<String, Long> byStatus = new java.util.LinkedHashMap<>();
|
java.util.Map<String, Long> byStatus = new java.util.LinkedHashMap<>();
|
||||||
for (String s : ALLOWED_STATUS) {
|
for (String s : ALLOWED_STATUS) {
|
||||||
|
|||||||
@ -17,6 +17,8 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
|||||||
long countByCategoryId(Long categoryId);
|
long countByCategoryId(Long categoryId);
|
||||||
long countByInterestStatus(String interestStatus);
|
long countByInterestStatus(String interestStatus);
|
||||||
long countBySource(String source);
|
long countBySource(String source);
|
||||||
|
long countByIsShortsTrue();
|
||||||
|
long countByCategoryIdIsNull();
|
||||||
|
|
||||||
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). */
|
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). */
|
||||||
@Query("select v from ChannelVideo v where v.isShorts = true "
|
@Query("select v from ChannelVideo v where v.isShorts = true "
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.hlab.yanalyst.domain.publish;
|
|||||||
|
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@ -9,4 +10,13 @@ import java.util.Optional;
|
|||||||
public interface PublishPackageRepository extends JpaRepository<PublishPackage, Long> {
|
public interface PublishPackageRepository extends JpaRepository<PublishPackage, Long> {
|
||||||
Optional<PublishPackage> findByChannelVideoId(Long channelVideoId);
|
Optional<PublishPackage> findByChannelVideoId(Long channelVideoId);
|
||||||
List<PublishPackage> findByStatus(String status, Sort sort);
|
List<PublishPackage> findByStatus(String status, Sort sort);
|
||||||
|
long countByStatus(String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 조회(정렬 포함). {@code findAll(Sort)}는 Criteria 기반이라 nullsLast/First 같은
|
||||||
|
* null precedence를 지원하지 않으므로(UnsupportedOperationException), Sort를 HQL ORDER BY로
|
||||||
|
* 붙이는 @Query 방식을 사용한다.
|
||||||
|
*/
|
||||||
|
@Query("select p from PublishPackage p")
|
||||||
|
List<PublishPackage> findAllSorted(Sort sort);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,12 +55,27 @@ public class PublishService {
|
|||||||
return repository.save(p);
|
return repository.save(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 대시보드용 발행 요약: 상태별 카운트 + 최근 5건. */
|
||||||
|
public java.util.Map<String, Object> dashboardSummary() {
|
||||||
|
java.util.Map<String, Long> byStatus = new java.util.LinkedHashMap<>();
|
||||||
|
for (String s : ALLOWED_STATUS) {
|
||||||
|
byStatus.put(s, repository.countByStatus(s));
|
||||||
|
}
|
||||||
|
List<PublishPackage> all = list(null);
|
||||||
|
List<PublishPackage> recent = all.size() > 5 ? all.subList(0, 5) : all;
|
||||||
|
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||||
|
result.put("byStatus", byStatus);
|
||||||
|
result.put("total", (long) all.size());
|
||||||
|
result.put("recent", recent);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** 발행 큐: 상태(null이면 전체)로 필터, 예약일 → 수정일 순. */
|
/** 발행 큐: 상태(null이면 전체)로 필터, 예약일 → 수정일 순. */
|
||||||
public List<PublishPackage> list(String status) {
|
public List<PublishPackage> list(String status) {
|
||||||
Sort sort = Sort.by(Sort.Order.asc("scheduledAt").nullsLast(), Sort.Order.desc("updatedAt"));
|
Sort sort = Sort.by(Sort.Order.asc("scheduledAt").nullsLast(), Sort.Order.desc("updatedAt"));
|
||||||
if (StringUtils.hasText(status)) {
|
if (StringUtils.hasText(status)) {
|
||||||
return repository.findByStatus(status, sort);
|
return repository.findByStatus(status, sort);
|
||||||
}
|
}
|
||||||
return repository.findAll(sort);
|
return repository.findAllSorted(sort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.hlab.yanalyst.service;
|
||||||
|
|
||||||
|
import com.hlab.yanalyst.domain.category.CategoryService;
|
||||||
|
import com.hlab.yanalyst.domain.channel.ChannelVideo;
|
||||||
|
import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService;
|
||||||
|
import com.hlab.yanalyst.domain.publish.PublishService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈 대시보드용 단일 집계 — 파이프라인(수집→큐레이션→발행) 현황을 한 번에 묶어 반환한다.
|
||||||
|
* 각 도메인 서비스의 집계를 조합만 한다(리포지토리 직접 접근 없음).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class DashboardService {
|
||||||
|
|
||||||
|
private final ChannelVideoCurationService curationService;
|
||||||
|
private final CategoryService categoryService;
|
||||||
|
private final PublishService publishService;
|
||||||
|
|
||||||
|
public Map<String, Object> summary() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("pipeline", curationService.pipelineStats()); // total, byStatus, bySource, shorts, longForm
|
||||||
|
result.put("categories", categoryService.distribution()); // categories[], uncategorized
|
||||||
|
result.put("publish", publishService.dashboardSummary()); // byStatus, total, recent
|
||||||
|
List<ChannelVideo> outperformers = curationService.findOutperformers(5, BigDecimal.ONE);
|
||||||
|
result.put("outperformers", outperformers);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.hlab.yanalyst.web;
|
||||||
|
|
||||||
|
import com.hlab.yanalyst.global.common.ApiResponse;
|
||||||
|
import com.hlab.yanalyst.service.DashboardService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/dashboard")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Dashboard API", description = "홈 대시보드 집계")
|
||||||
|
public class DashboardApiController {
|
||||||
|
|
||||||
|
private final DashboardService dashboardService;
|
||||||
|
|
||||||
|
@GetMapping("/summary")
|
||||||
|
@Operation(summary = "대시보드 요약",
|
||||||
|
description = "파이프라인(수집/상태/출처/포맷) + 카테고리 분포 + 발행 현황 + 떡상 후보 TOP5 를 한 번에 반환.")
|
||||||
|
public ApiResponse<Map<String, Object>> summary() {
|
||||||
|
return ApiResponse.ok(dashboardService.summary());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,109 +9,219 @@
|
|||||||
<body>
|
<body>
|
||||||
<div layout:fragment="content">
|
<div layout:fragment="content">
|
||||||
<header class="mb-4">
|
<header class="mb-4">
|
||||||
<h1 class="text-xl font-bold mb-4">Dashboard</h1>
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-muted">Overview of your YouTube analytics and tracking.</p>
|
<div>
|
||||||
|
<h1 class="text-xl font-bold mb-1">Dashboard</h1>
|
||||||
|
<p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" onclick="loadDashboard()">
|
||||||
|
<i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Stats Grid -->
|
<!-- KPI 카드 -->
|
||||||
<div class="flex gap-4 mb-4"
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.25rem; margin-bottom: 1.5rem;">
|
||||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
|
||||||
<div class="card flex flex-col justify-between">
|
<div class="card flex flex-col justify-between">
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div><p class="text-sm text-muted">수집 영상</p><h3 class="text-xl font-bold" id="kTotal">-</h3></div>
|
||||||
<p class="text-sm text-muted">수집 영상</p>
|
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="library" color="var(--primary)"></i></div>
|
||||||
<h3 class="text-xl font-bold" id="statTotal">-</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
|
<div class="text-sm text-muted" id="kTotalCap">채널 - · 검색 -</div>
|
||||||
<i data-lucide="library" color="var(--primary)"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="text-sm text-muted" id="statSourceCaption">채널 + 검색 수집</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card flex flex-col justify-between">
|
<div class="card flex flex-col justify-between">
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div><p class="text-sm text-muted">미검토 (NEW)</p><h3 class="text-xl font-bold" id="kNew">-</h3></div>
|
||||||
<p class="text-sm text-muted">작업대상 (TARGET)</p>
|
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="inbox" color="var(--primary)"></i></div>
|
||||||
<h3 class="text-xl font-bold" id="statTarget">-</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
|
<div class="text-sm text-muted" id="kReviewCap">검토중 -</div>
|
||||||
<i data-lucide="clapperboard" color="var(--primary)"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="text-sm text-muted" id="statReviewCaption">검토중 -</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card flex flex-col justify-between">
|
<div class="card flex flex-col justify-between">
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div><p class="text-sm text-muted">작업대상 (TARGET)</p><h3 class="text-xl font-bold" id="kTarget">-</h3></div>
|
||||||
<p class="text-sm text-muted">미검토 (NEW)</p>
|
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="clapperboard" color="var(--primary)"></i></div>
|
||||||
<h3 class="text-xl font-bold" id="statNew">-</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
|
<div class="text-sm text-muted">재가공 대상</div>
|
||||||
<i data-lucide="inbox" color="var(--primary)"></i>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card flex flex-col justify-between">
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<div><p class="text-sm text-muted">완료 (DONE)</p><h3 class="text-xl font-bold" id="kDone">-</h3></div>
|
||||||
|
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="check-circle" color="var(--primary)"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-muted" id="statExcludedCaption">제외 -</div>
|
<div class="text-sm text-muted" id="kExcludedCap">제외 -</div>
|
||||||
|
</div>
|
||||||
|
<div class="card flex flex-col justify-between">
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<div><p class="text-sm text-muted">발행완료</p><h3 class="text-xl font-bold" id="kPublished">-</h3></div>
|
||||||
|
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="send" color="var(--primary)"></i></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted" id="kPublishCap">대기 - · 작성중 -</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
|
<!-- 파이프라인 깔때기 -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-bold">파이프라인 현황</h3>
|
||||||
|
<span class="text-sm text-muted" id="funnelExcluded"></span>
|
||||||
|
</div>
|
||||||
|
<div id="funnel" class="flex flex-col gap-3">
|
||||||
|
<p class="text-muted text-sm">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 떡상 / 발행 -->
|
||||||
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-bold">🚀 떡상 후보 TOP</h3>
|
<h3 class="text-lg font-bold">🚀 떡상 후보 TOP</h3>
|
||||||
<a th:href="@{/collection}" class="text-sm text-muted hover:text-white">수집함 →</a>
|
<a th:href="@{/discover}" class="text-sm text-muted hover:text-white">발굴 →</a>
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2" id="opList">
|
|
||||||
<p class="text-muted text-sm">로딩 중...</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-2" id="opList"><p class="text-muted text-sm">로딩 중...</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="text-lg font-bold mb-4">수집 출처</h3>
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex flex-col gap-2" id="sourceList">
|
<h3 class="text-lg font-bold">발행 현황</h3>
|
||||||
<p class="text-muted text-sm">로딩 중...</p>
|
<a th:href="@{/publish}" class="text-sm text-muted hover:text-white">발행 큐 →</a>
|
||||||
|
</div>
|
||||||
|
<div id="publishBars" class="flex flex-col gap-2 mb-3"></div>
|
||||||
|
<div id="publishRecent" class="flex flex-col gap-1"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 카테고리 / 출처·포맷 -->
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-bold">카테고리 분포</h3>
|
||||||
|
<a th:href="@{/collection}" class="text-sm text-muted hover:text-white">수집함 →</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="catList" class="flex flex-col gap-2"><p class="text-muted text-sm">로딩 중...</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-lg font-bold mb-4">수집 출처 & 포맷</h3>
|
||||||
|
<div id="sourceFormat" class="flex flex-col gap-2"><p class="text-muted text-sm">로딩 중...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar-track { flex:1; height:10px; background:rgba(255,255,255,0.06); border-radius:999px; overflow:hidden; }
|
||||||
|
.bar-fill { height:100%; border-radius:999px; }
|
||||||
|
.st-badge { font-size:0.65rem; padding:1px 7px; border-radius:999px; font-weight:bold; }
|
||||||
|
</style>
|
||||||
|
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
/*<![CDATA[*/
|
/*<![CDATA[*/
|
||||||
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
async function getData(url){ const r = await fetch(url); const j = await r.json().catch(()=>({})); return j.data; }
|
function fmt(n){ return (n==null) ? '0' : Number(n).toLocaleString(); }
|
||||||
|
function pct(n, total){ return total > 0 ? Math.round((Number(n||0)/total)*100) : 0; }
|
||||||
|
|
||||||
(async ()=>{
|
async function getData(url){
|
||||||
try {
|
const r = await fetch(url);
|
||||||
const s = await getData('/api/v1/channel-videos/stats');
|
const j = await r.json().catch(()=>({}));
|
||||||
if(s){
|
if(!r.ok || (j && j.success===false)) throw new Error((j && j.message) || ('HTTP '+r.status));
|
||||||
document.getElementById('statTotal').textContent = Number(s.total||0).toLocaleString();
|
return j.data;
|
||||||
const bs = s.byStatus||{}, src = s.bySource||{};
|
|
||||||
document.getElementById('statTarget').textContent = Number(bs.TARGET||0).toLocaleString();
|
|
||||||
document.getElementById('statNew').textContent = Number(bs.NEW||0).toLocaleString();
|
|
||||||
document.getElementById('statReviewCaption').textContent = '검토중 ' + (bs.REVIEWING||0);
|
|
||||||
document.getElementById('statExcludedCaption').textContent = '제외 ' + (bs.EXCLUDED||0);
|
|
||||||
document.getElementById('statSourceCaption').textContent = '채널 ' + (src.CHANNEL||0) + ' · 검색 ' + (src.SEARCH||0);
|
|
||||||
document.getElementById('sourceList').innerHTML =
|
|
||||||
'<div class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03);"><span class="text-sm">채널 수집</span><span class="text-sm font-bold">'+(src.CHANNEL||0)+'</span></div>' +
|
|
||||||
'<div class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03);"><span class="text-sm">검색 수집</span><span class="text-sm font-bold">'+(src.SEARCH||0)+'</span></div>';
|
|
||||||
}
|
}
|
||||||
} catch(e){ console.error(e); }
|
|
||||||
|
|
||||||
try {
|
function bar(label, count, total, color){
|
||||||
const op = await getData('/api/v1/channel-videos/outperformers?limit=5&minRatio=1') || [];
|
const p = pct(count, total);
|
||||||
const list = document.getElementById('opList');
|
return `<div class="flex items-center gap-3">
|
||||||
if(op.length===0){ list.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; return; }
|
<span class="text-sm" style="width:92px; flex-shrink:0;">${esc(label)}</span>
|
||||||
list.innerHTML = op.map(v=>{
|
<div class="bar-track"><div class="bar-fill" style="width:${p}%; background:${color};"></div></div>
|
||||||
|
<span class="text-sm font-bold" style="width:70px; text-align:right; flex-shrink:0;">${fmt(count)} <span class="text-muted" style="font-weight:normal;">(${p}%)</span></span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard(){
|
||||||
|
let d;
|
||||||
|
try { d = await getData('/api/dashboard/summary'); }
|
||||||
|
catch(e){
|
||||||
|
document.getElementById('funnel').innerHTML = '<p class="text-danger text-sm">불러오기 실패: '+esc(e.message)+'</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pipe = d.pipeline || {};
|
||||||
|
const bs = pipe.byStatus || {}, src = pipe.bySource || {};
|
||||||
|
const total = Number(pipe.total || 0);
|
||||||
|
const pub = d.publish || {}, pbs = pub.byStatus || {};
|
||||||
|
|
||||||
|
// KPI
|
||||||
|
document.getElementById('kTotal').textContent = fmt(total);
|
||||||
|
document.getElementById('kTotalCap').textContent = '채널 ' + fmt(src.CHANNEL) + ' · 검색 ' + fmt(src.SEARCH);
|
||||||
|
document.getElementById('kNew').textContent = fmt(bs.NEW);
|
||||||
|
document.getElementById('kReviewCap').textContent = '검토중 ' + fmt(bs.REVIEWING);
|
||||||
|
document.getElementById('kTarget').textContent = fmt(bs.TARGET);
|
||||||
|
document.getElementById('kDone').textContent = fmt(bs.DONE);
|
||||||
|
document.getElementById('kExcludedCap').textContent = '제외 ' + fmt(bs.EXCLUDED);
|
||||||
|
document.getElementById('kPublished').textContent = fmt(pbs.PUBLISHED);
|
||||||
|
document.getElementById('kPublishCap').textContent = '대기 ' + fmt(pbs.READY) + ' · 작성중 ' + fmt(pbs.DRAFT);
|
||||||
|
|
||||||
|
// 깔때기 (총 수집 대비)
|
||||||
|
document.getElementById('funnelExcluded').textContent = '총 ' + fmt(total) + '건 · 제외 ' + fmt(bs.EXCLUDED);
|
||||||
|
document.getElementById('funnel').innerHTML =
|
||||||
|
bar('미검토', bs.NEW, total, '#64748b') +
|
||||||
|
bar('검토중', bs.REVIEWING, total, '#38bdf8') +
|
||||||
|
bar('작업대상', bs.TARGET, total, '#f59e0b') +
|
||||||
|
bar('완료', bs.DONE, total, '#10b981') +
|
||||||
|
bar('발행완료', pbs.PUBLISHED, total, '#7C3AED');
|
||||||
|
|
||||||
|
// 떡상 TOP
|
||||||
|
const op = d.outperformers || [];
|
||||||
|
const opList = document.getElementById('opList');
|
||||||
|
if(op.length===0){ opList.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; }
|
||||||
|
else {
|
||||||
|
opList.innerHTML = op.map(v=>{
|
||||||
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '-';
|
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '-';
|
||||||
return '<a href="https://www.youtube.com/watch?v='+v.videoId+'" target="_blank" class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03); text-decoration:none;">' +
|
return '<a href="https://www.youtube.com/watch?v='+v.videoId+'" target="_blank" class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03); text-decoration:none;">' +
|
||||||
'<div class="flex items-center gap-2" style="min-width:0;">' +
|
'<div class="flex items-center gap-2" style="min-width:0;">' +
|
||||||
'<img src="'+esc(v.thumbnailUrl)+'" style="width:48px;height:27px;object-fit:cover;border-radius:4px;flex-shrink:0;">' +
|
'<img src="'+esc(v.thumbnailUrl)+'" style="width:48px;height:27px;object-fit:cover;border-radius:4px;flex-shrink:0;">' +
|
||||||
'<div style="min-width:0;"><div class="text-sm font-bold truncate" style="max-width:280px;">'+esc(v.title)+'</div>' +
|
'<div style="min-width:0;"><div class="text-sm font-bold truncate" style="max-width:300px;">'+esc(v.title)+'</div>' +
|
||||||
'<div class="text-muted" style="font-size:0.7rem;">'+esc(v.channelTitle||'')+' · 조회 '+Number(v.viewCount||0).toLocaleString()+'</div></div>' +
|
'<div class="text-muted" style="font-size:0.7rem;">'+esc(v.channelTitle||'')+' · 조회 '+fmt(v.viewCount)+'</div></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<span style="color:#ef4444; font-weight:bold; font-size:0.8rem; flex-shrink:0;">'+ratio+'</span></a>';
|
'<span style="color:#ef4444; font-weight:bold; font-size:0.8rem; flex-shrink:0;">'+ratio+'</span></a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch(e){ console.error(e); }
|
}
|
||||||
})();
|
|
||||||
|
// 발행 현황
|
||||||
|
const ptotal = Number(pub.total || 0);
|
||||||
|
document.getElementById('publishBars').innerHTML =
|
||||||
|
bar('작성중', pbs.DRAFT, ptotal, '#64748b') +
|
||||||
|
bar('발행대기', pbs.READY, ptotal, '#f59e0b') +
|
||||||
|
bar('발행완료', pbs.PUBLISHED, ptotal, '#10b981');
|
||||||
|
const recent = pub.recent || [];
|
||||||
|
const PUB_ST = { DRAFT:{t:'작성중',c:'#64748b'}, READY:{t:'대기',c:'#f59e0b'}, PUBLISHED:{t:'완료',c:'#10b981'} };
|
||||||
|
document.getElementById('publishRecent').innerHTML = recent.length===0
|
||||||
|
? '<p class="text-muted text-sm" style="margin-top:4px;">발행 패키지가 없습니다.</p>'
|
||||||
|
: '<div class="text-sm text-muted" style="margin:6px 0 2px;">최근</div>' + recent.map(p=>{
|
||||||
|
const st = PUB_ST[p.status] || {t:p.status,c:'#64748b'};
|
||||||
|
return '<a href="/rework/'+p.channelVideoId+'" class="flex items-center justify-between p-2" style="border-radius:6px; background:rgba(255,255,255,0.03); text-decoration:none;">' +
|
||||||
|
'<span class="text-sm truncate" style="max-width:170px; color:#e2e8f0;">'+esc(p.title||'(제목 없음)')+'</span>' +
|
||||||
|
'<span class="st-badge" style="background:'+st.c+'22; color:'+st.c+'; flex-shrink:0;">'+st.t+'</span></a>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// 카테고리 분포
|
||||||
|
const cats = (d.categories && d.categories.categories) || [];
|
||||||
|
const uncat = (d.categories && d.categories.uncategorized) || 0;
|
||||||
|
const catList = document.getElementById('catList');
|
||||||
|
if(cats.length===0 && !uncat){ catList.innerHTML = '<p class="text-muted text-sm">분류된 영상이 없습니다.</p>'; }
|
||||||
|
else {
|
||||||
|
catList.innerHTML = cats.map(c => bar(c.name, c.count, total, c.color || '#7C3AED')).join('') +
|
||||||
|
bar('미분류', uncat, total, '#475569');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 출처 & 포맷
|
||||||
|
const shorts = Number(pipe.shorts || 0), longForm = Number(pipe.longForm || 0);
|
||||||
|
document.getElementById('sourceFormat').innerHTML =
|
||||||
|
'<div class="text-sm text-muted" style="margin-bottom:2px;">출처</div>' +
|
||||||
|
bar('채널 수집', src.CHANNEL, total, '#38bdf8') +
|
||||||
|
bar('검색 수집', src.SEARCH, total, '#a78bfa') +
|
||||||
|
'<div class="text-sm text-muted" style="margin:10px 0 2px;">포맷</div>' +
|
||||||
|
bar('Shorts', shorts, total, '#ef4444') +
|
||||||
|
bar('롱폼', longForm, total, '#10b981');
|
||||||
|
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDashboard();
|
||||||
/*]]>*/
|
/*]]>*/
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user