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) {
|
||||
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() {
|
||||
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<>();
|
||||
for (String s : ALLOWED_STATUS) {
|
||||
|
||||
@ -17,6 +17,8 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
||||
long countByCategoryId(Long categoryId);
|
||||
long countByInterestStatus(String interestStatus);
|
||||
long countBySource(String source);
|
||||
long countByIsShortsTrue();
|
||||
long countByCategoryIdIsNull();
|
||||
|
||||
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). */
|
||||
@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.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@ -9,4 +10,13 @@ import java.util.Optional;
|
||||
public interface PublishPackageRepository extends JpaRepository<PublishPackage, Long> {
|
||||
Optional<PublishPackage> findByChannelVideoId(Long channelVideoId);
|
||||
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);
|
||||
}
|
||||
|
||||
/** 대시보드용 발행 요약: 상태별 카운트 + 최근 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이면 전체)로 필터, 예약일 → 수정일 순. */
|
||||
public List<PublishPackage> list(String status) {
|
||||
Sort sort = Sort.by(Sort.Order.asc("scheduledAt").nullsLast(), Sort.Order.desc("updatedAt"));
|
||||
if (StringUtils.hasText(status)) {
|
||||
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,112 +9,222 @@
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<header class="mb-4">
|
||||
<h1 class="text-xl font-bold mb-4">Dashboard</h1>
|
||||
<p class="text-muted">Overview of your YouTube analytics and tracking.</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="flex gap-4 mb-4"
|
||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
||||
<!-- KPI 카드 -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.25rem; margin-bottom: 1.5rem;">
|
||||
<div class="card flex flex-col justify-between">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted">수집 영상</p>
|
||||
<h3 class="text-xl font-bold" id="statTotal">-</h3>
|
||||
</div>
|
||||
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
|
||||
<i data-lucide="library" color="var(--primary)"></i>
|
||||
</div>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div><p class="text-sm text-muted">수집 영상</p><h3 class="text-xl font-bold" id="kTotal">-</h3></div>
|
||||
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="library" color="var(--primary)"></i></div>
|
||||
</div>
|
||||
<div class="text-sm text-muted" id="statSourceCaption">채널 + 검색 수집</div>
|
||||
<div class="text-sm text-muted" id="kTotalCap">채널 - · 검색 -</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col justify-between">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted">작업대상 (TARGET)</p>
|
||||
<h3 class="text-xl font-bold" id="statTarget">-</h3>
|
||||
</div>
|
||||
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
|
||||
<i data-lucide="clapperboard" color="var(--primary)"></i>
|
||||
</div>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div><p class="text-sm text-muted">미검토 (NEW)</p><h3 class="text-xl font-bold" id="kNew">-</h3></div>
|
||||
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="inbox" color="var(--primary)"></i></div>
|
||||
</div>
|
||||
<div class="text-sm text-muted" id="statReviewCaption">검토중 -</div>
|
||||
<div class="text-sm text-muted" id="kReviewCap">검토중 -</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col justify-between">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted">미검토 (NEW)</p>
|
||||
<h3 class="text-xl font-bold" id="statNew">-</h3>
|
||||
</div>
|
||||
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
|
||||
<i data-lucide="inbox" color="var(--primary)"></i>
|
||||
</div>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div><p class="text-sm text-muted">작업대상 (TARGET)</p><h3 class="text-xl font-bold" id="kTarget">-</h3></div>
|
||||
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="clapperboard" color="var(--primary)"></i></div>
|
||||
</div>
|
||||
<div class="text-sm text-muted" id="statExcludedCaption">제외 -</div>
|
||||
<div class="text-sm text-muted">재가공 대상</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 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 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="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold">🚀 떡상 후보 TOP</h3>
|
||||
<a th:href="@{/collection}" 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>
|
||||
<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="card">
|
||||
<h3 class="text-lg font-bold mb-4">수집 출처</h3>
|
||||
<div class="flex flex-col gap-2" id="sourceList">
|
||||
<p class="text-muted text-sm">로딩 중...</p>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold">발행 현황</h3>
|
||||
<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 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 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">
|
||||
/*<![CDATA[*/
|
||||
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 ()=>{
|
||||
try {
|
||||
const s = await getData('/api/v1/channel-videos/stats');
|
||||
if(s){
|
||||
document.getElementById('statTotal').textContent = Number(s.total||0).toLocaleString();
|
||||
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); }
|
||||
async function getData(url){
|
||||
const r = await fetch(url);
|
||||
const j = await r.json().catch(()=>({}));
|
||||
if(!r.ok || (j && j.success===false)) throw new Error((j && j.message) || ('HTTP '+r.status));
|
||||
return j.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const op = await getData('/api/v1/channel-videos/outperformers?limit=5&minRatio=1') || [];
|
||||
const list = document.getElementById('opList');
|
||||
if(op.length===0){ list.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; return; }
|
||||
list.innerHTML = op.map(v=>{
|
||||
function bar(label, count, total, color){
|
||||
const p = pct(count, total);
|
||||
return `<div class="flex items-center gap-3">
|
||||
<span class="text-sm" style="width:92px; flex-shrink:0;">${esc(label)}</span>
|
||||
<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' : '-';
|
||||
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;">' +
|
||||
'<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 class="text-muted" style="font-size:0.7rem;">'+esc(v.channelTitle||'')+' · 조회 '+Number(v.viewCount||0).toLocaleString()+'</div></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||'')+' · 조회 '+fmt(v.viewCount)+'</div></div>' +
|
||||
'</div>' +
|
||||
'<span style="color:#ef4444; font-weight:bold; font-size:0.8rem; flex-shrink:0;">'+ratio+'</span></a>';
|
||||
}).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>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user