feat: 소재 중심 대시보드와 조회수 현행화
This commit is contained in:
parent
381ec71291
commit
51fcfffffa
@ -27,6 +27,7 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -50,6 +51,41 @@ public class ChannelService {
|
|||||||
@Value("${python.base-url:http://h-python.tolag.shop}") // Python 마이크로서비스(자막/전사) 베이스 URL
|
@Value("${python.base-url:http://h-python.tolag.shop}") // Python 마이크로서비스(자막/전사) 베이스 URL
|
||||||
private String pythonBaseUrl;
|
private String pythonBaseUrl;
|
||||||
|
|
||||||
|
/** 저장된 피드 영상의 조회수·좋아요와 파생 지표를 videos.list 배치 응답으로 현행화한다. */
|
||||||
|
@Transactional
|
||||||
|
public int refreshVideoStats(List<ChannelVideo> videos) {
|
||||||
|
if (videos == null || videos.isEmpty()) return 0;
|
||||||
|
Map<String, ChannelVideo> byVideoId = videos.stream().collect(java.util.stream.Collectors.toMap(
|
||||||
|
ChannelVideo::getVideoId, v -> v, (left, right) -> left));
|
||||||
|
int updated = 0;
|
||||||
|
List<String> ids = new ArrayList<>(byVideoId.keySet());
|
||||||
|
for (int from = 0; from < ids.size(); from += 50) {
|
||||||
|
List<String> batch = ids.subList(from, Math.min(from + 50, ids.size()));
|
||||||
|
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
||||||
|
.queryParam("part", "statistics")
|
||||||
|
.queryParam("id", String.join(",", batch))
|
||||||
|
.queryParam("key", youtubeApiKey)
|
||||||
|
.toUriString();
|
||||||
|
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
||||||
|
for (JsonNode item : root.path("items")) {
|
||||||
|
ChannelVideo video = byVideoId.get(item.path("id").asText());
|
||||||
|
if (video == null) continue;
|
||||||
|
JsonNode statistics = item.path("statistics");
|
||||||
|
long views = statistics.path("viewCount").asLong(video.getViewCount() == null ? 0 : video.getViewCount());
|
||||||
|
long likes = statistics.path("likeCount").asLong(video.getLikeCount() == null ? 0 : video.getLikeCount());
|
||||||
|
video.update(video.getTitle(), video.getThumbnailUrl(), views, likes);
|
||||||
|
video.applyMetrics(video.getDurationSec(), video.getIsShorts(),
|
||||||
|
VideoMetrics.viewsPerHour(views, video.getPublishedAt()));
|
||||||
|
video.applyFeedInfo(video.getYtChannelId(), video.getChannelTitle(), video.getSubscriberCount(),
|
||||||
|
VideoMetrics.viewsPerSubRatio(views, video.getSubscriberCount()),
|
||||||
|
video.getSource(), video.feedTopicOrDefault());
|
||||||
|
channelVideoRepository.save(video);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Channel saveChannelFromUrl(String url) {
|
public Channel saveChannelFromUrl(String url) {
|
||||||
String identifier = extractIdentifier(url);
|
String identifier = extractIdentifier(url);
|
||||||
|
|||||||
@ -12,6 +12,10 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
|||||||
java.util.List<ChannelVideo> findByChannelId(Long channelId);
|
java.util.List<ChannelVideo> findByChannelId(Long channelId);
|
||||||
java.util.List<ChannelVideo> findByChannelIdInOrderByPublishedAtDesc(java.util.List<Long> channelIds);
|
java.util.List<ChannelVideo> findByChannelIdInOrderByPublishedAtDesc(java.util.List<Long> channelIds);
|
||||||
|
|
||||||
|
@Query("select v from ChannelVideo v where coalesce(v.feedTopic, 'ENTERTAINMENT') = :topic "
|
||||||
|
+ "and v.source in ('SOURCE','RIVAL') order by v.publishedAt desc")
|
||||||
|
java.util.List<ChannelVideo> findFeedVideosForRefresh(@Param("topic") String topic);
|
||||||
|
|
||||||
// --- 큐레이션/분석 조회 ---
|
// --- 큐레이션/분석 조회 ---
|
||||||
java.util.List<ChannelVideo> findByCategoryId(Long categoryId);
|
java.util.List<ChannelVideo> findByCategoryId(Long categoryId);
|
||||||
long countByCategoryId(Long categoryId);
|
long countByCategoryId(Long categoryId);
|
||||||
|
|||||||
@ -31,6 +31,7 @@ public class FeedCollectionService {
|
|||||||
private static final long EST_UNITS_PER_CHANNEL = 2;
|
private static final long EST_UNITS_PER_CHANNEL = 2;
|
||||||
|
|
||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
|
private final ChannelVideoRepository channelVideoRepository;
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
private final YoutubeQuotaGuard quotaGuard;
|
private final YoutubeQuotaGuard quotaGuard;
|
||||||
private final FeedAlertService feedAlertService;
|
private final FeedAlertService feedAlertService;
|
||||||
@ -119,6 +120,25 @@ public class FeedCollectionService {
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 저장된 소재 피드 영상의 조회수만 저비용 videos.list 배치로 현행화한다. */
|
||||||
|
public Map<String, Object> refreshViews(FeedTopic topic) {
|
||||||
|
FeedTopic normalized = topic == null ? FeedTopic.ENTERTAINMENT : topic;
|
||||||
|
List<ChannelVideo> videos = channelVideoRepository.findFeedVideosForRefresh(normalized.name());
|
||||||
|
int batches = (videos.size() + 49) / 50;
|
||||||
|
if (!quotaGuard.tryConsume(batches)) {
|
||||||
|
throw new IllegalStateException("YouTube API 쿼터가 부족합니다. 필요 " + batches
|
||||||
|
+ " units · 잔여 " + quotaGuard.remaining() + " units");
|
||||||
|
}
|
||||||
|
int updated = channelService.refreshVideoStats(videos);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("requested", videos.size());
|
||||||
|
result.put("updated", updated);
|
||||||
|
result.put("quotaUsed", batches);
|
||||||
|
result.put("quotaRemaining", quotaGuard.remaining());
|
||||||
|
result.put("refreshedAt", LocalDateTime.now());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 수집 성공 표시(실패 카운터 리셋).
|
* 수집 성공 표시(실패 카운터 리셋).
|
||||||
* 같은 빈 안에서 호출되므로 @Transactional 프록시가 적용되지 않는다 — 명시적으로 save 한다.
|
* 같은 빈 안에서 호출되므로 @Transactional 프록시가 적용되지 않는다 — 명시적으로 save 한다.
|
||||||
|
|||||||
@ -86,6 +86,13 @@ public class FeedController {
|
|||||||
return ApiResponse.ok(feedCollectionService.collect(FeedTopic.normalize(topic)));
|
return ApiResponse.ok(feedCollectionService.collect(FeedTopic.normalize(topic)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh-views")
|
||||||
|
@Operation(summary = "소재 조회수 현행화", description = "저장된 피드 영상 조회수를 videos.list 50개 배치로 갱신한다")
|
||||||
|
public ApiResponse<Map<String, Object>> refreshViews(
|
||||||
|
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
|
||||||
|
return ApiResponse.ok(feedCollectionService.refreshViews(FeedTopic.normalize(topic)));
|
||||||
|
}
|
||||||
|
|
||||||
// --- 시드(소스/경쟁 채널) 관리 ---
|
// --- 시드(소스/경쟁 채널) 관리 ---
|
||||||
|
|
||||||
@GetMapping("/seeds")
|
@GetMapping("/seeds")
|
||||||
|
|||||||
@ -24,6 +24,7 @@ public record FeedItemDto(
|
|||||||
String interestStatus,
|
String interestStatus,
|
||||||
boolean bookmarked,
|
boolean bookmarked,
|
||||||
String source,
|
String source,
|
||||||
|
String topic,
|
||||||
/** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */
|
/** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */
|
||||||
String matchedPerson,
|
String matchedPerson,
|
||||||
/** 사이트 안에서 재생 가능한가. null 이면 아직 모름 — 일단 재생을 시도한다. */
|
/** 사이트 안에서 재생 가능한가. null 이면 아직 모름 — 일단 재생을 시도한다. */
|
||||||
@ -52,6 +53,7 @@ public record FeedItemDto(
|
|||||||
v.getInterestStatus(),
|
v.getInterestStatus(),
|
||||||
v.isBookmarked(),
|
v.isBookmarked(),
|
||||||
v.getSource(),
|
v.getSource(),
|
||||||
|
v.feedTopicOrDefault().name(),
|
||||||
v.getMatchedPerson(),
|
v.getMatchedPerson(),
|
||||||
v.getEmbeddable(),
|
v.getEmbeddable(),
|
||||||
FeedBadges.isGoldenTime(v.getPublishedAt(), now),
|
FeedBadges.isGoldenTime(v.getPublishedAt(), now),
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package com.hlab.yanalyst.service;
|
|||||||
import com.hlab.yanalyst.domain.category.CategoryService;
|
import com.hlab.yanalyst.domain.category.CategoryService;
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelVideo;
|
import com.hlab.yanalyst.domain.channel.ChannelVideo;
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService;
|
import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService;
|
||||||
|
import com.hlab.yanalyst.domain.channel.FeedService;
|
||||||
|
import com.hlab.yanalyst.domain.channel.dto.FeedItemDto;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@ -23,6 +25,7 @@ public class DashboardService {
|
|||||||
|
|
||||||
private final ChannelVideoCurationService curationService;
|
private final ChannelVideoCurationService curationService;
|
||||||
private final CategoryService categoryService;
|
private final CategoryService categoryService;
|
||||||
|
private final FeedService feedService;
|
||||||
|
|
||||||
public Map<String, Object> summary() {
|
public Map<String, Object> summary() {
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
@ -30,6 +33,23 @@ public class DashboardService {
|
|||||||
result.put("categories", categoryService.distribution()); // categories[], uncategorized
|
result.put("categories", categoryService.distribution()); // categories[], uncategorized
|
||||||
List<ChannelVideo> outperformers = curationService.findOutperformers(5, BigDecimal.ONE);
|
List<ChannelVideo> outperformers = curationService.findOutperformers(5, BigDecimal.ONE);
|
||||||
result.put("outperformers", outperformers);
|
result.put("outperformers", outperformers);
|
||||||
|
List<FeedItemDto> entertainment = feedService.feed("SOURCE", "ENTERTAINMENT", 14,
|
||||||
|
null, null, false, null);
|
||||||
|
List<FeedItemDto> politics = feedService.feed("SOURCE", "POLITICS", 14,
|
||||||
|
null, null, false, null);
|
||||||
|
List<FeedItemDto> all = new java.util.ArrayList<>(entertainment);
|
||||||
|
all.addAll(politics);
|
||||||
|
all.sort(java.util.Comparator.comparing(FeedItemDto::viewsPerHour,
|
||||||
|
java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())));
|
||||||
|
Map<String, Object> feedDesk = new LinkedHashMap<>();
|
||||||
|
feedDesk.put("total", all.size());
|
||||||
|
feedDesk.put("entertainment", entertainment.size());
|
||||||
|
feedDesk.put("politics", politics.size());
|
||||||
|
feedDesk.put("golden", all.stream().filter(FeedItemDto::goldenTime).count());
|
||||||
|
feedDesk.put("unworked", all.stream().filter(v -> !v.worked()).count());
|
||||||
|
feedDesk.put("bookmarked", all.stream().filter(FeedItemDto::bookmarked).count());
|
||||||
|
feedDesk.put("top", all.stream().limit(10).toList());
|
||||||
|
result.put("feedDesk", feedDesk);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,308 +1,76 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
layout:decorate="~{layout/base}">
|
layout:decorate="~{layout/base}">
|
||||||
|
<head><title>h-lab - 소재 데스크</title></head>
|
||||||
<head>
|
|
||||||
<title>h-lab - 대시보드</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div layout:fragment="content">
|
<div layout:fragment="content">
|
||||||
<div class="fd-dash">
|
<main class="md-desk">
|
||||||
|
<header class="md-head">
|
||||||
<div class="fd-masthead">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="fd-tick" style="margin-bottom:8px;">지금 할 일 중심 · 수집 → 큐레이션 → 재가공</div>
|
<span class="md-kicker">MATERIAL DESK · 최근 14일</span>
|
||||||
<h1>대시보드</h1>
|
<h1>오늘 만들 소재</h1>
|
||||||
</div>
|
<p>업로드 시점보다 지금 오르는 속도를 먼저 봅니다.</p>
|
||||||
<div class="fd-meta">
|
|
||||||
<div class="fd-tick">PIPELINE STATUS</div>
|
|
||||||
<div class="fd-big" id="fdDate">—</div>
|
|
||||||
<button class="fd-btn fd-sm" onclick="loadDashboard()" style="margin-top:8px;">
|
|
||||||
<i data-lucide="refresh-cw" style="width:13px;"></i> 새로고침
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="md-actions">
|
||||||
|
<span id="updatedAt">조회 중</span>
|
||||||
|
<button class="md-btn" id="refreshViewsBtn" onclick="refreshViews()">조회수 현행화</button>
|
||||||
|
<button class="md-btn ghost" onclick="loadDashboard()">화면 새로고침</button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="fd-body fd-stagger">
|
<section class="md-score" aria-label="소재 요약">
|
||||||
|
<a href="/feed" class="md-score-main">
|
||||||
<!-- ACTION BAR -->
|
<span>검토할 소재</span><strong id="sUnworked">—</strong><small>아직 손대지 않은 원본</small>
|
||||||
<section class="fd-actions" style="animation-delay:.02s">
|
|
||||||
<a class="fd-act fd-hot" th:href="@{/board}">
|
|
||||||
<div class="fd-act-top">
|
|
||||||
<div class="fd-num" id="aNew">–</div>
|
|
||||||
<div class="fd-act-lab"><div class="t">미검토 영상</div><div class="s" id="aNewSub">NEW · 검토 대기</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="fd-cta-row"><span class="fd-btn fd-sig">검토 시작 →</span></div>
|
|
||||||
</a>
|
|
||||||
<a class="fd-act" th:href="@{/discover}">
|
|
||||||
<div class="fd-act-top">
|
|
||||||
<div class="fd-num" id="aOut" style="color:var(--fd-signal)">–</div>
|
|
||||||
<div class="fd-act-lab"><div class="t">떡상 후보</div><div class="s" id="aOutSub">배율 상위</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="fd-cta-row"><span class="fd-btn">발굴 열기 →</span></div>
|
|
||||||
</a>
|
|
||||||
<a class="fd-act" th:href="@{/collection(status='TARGET')}">
|
|
||||||
<div class="fd-act-top">
|
|
||||||
<div class="fd-num" id="aTarget" style="color:var(--fd-ink-3)">–</div>
|
|
||||||
<div class="fd-act-lab"><div class="t">재가공 대기</div><div class="s">TARGET</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="fd-muted-cta" id="aTargetCta">발굴에서 후보를 TARGET으로 보내세요 →</div>
|
|
||||||
</a>
|
</a>
|
||||||
|
<div class="md-metric hot"><span>24시간 이내</span><strong id="sGolden">—</strong><small>골든타임</small></div>
|
||||||
|
<div class="md-metric"><span>예능</span><strong id="sEntertainment">—</strong><small>최근 14일</small></div>
|
||||||
|
<div class="md-metric"><span>정치</span><strong id="sPolitics">—</strong><small>최근 14일</small></div>
|
||||||
|
<div class="md-metric"><span>북마크</span><strong id="sBookmarked">—</strong><small>다시 볼 소재</small></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- WORK SURFACE -->
|
<section class="md-board">
|
||||||
<section class="fd-cols" style="animation-delay:.1s">
|
<div class="md-board-head">
|
||||||
<div class="fd-panel">
|
<div><span class="md-kicker">VELOCITY RANK</span><h2>지금 빠르게 오르는 소재</h2></div>
|
||||||
<div class="fd-ph"><h3>떡상 후보 — 바로 처리</h3><a class="fd-tick" style="color:var(--fd-signal)" th:href="@{/discover}">발굴 전체 →</a></div>
|
<div class="md-tabs"><a href="/feed">예능 피드</a><a href="/politics-feed">정치 피드</a></div>
|
||||||
<div class="fd-pb" id="leadList"><div class="fd-loading">불러오는 중…</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fd-panel">
|
|
||||||
<div class="fd-ph"><h3>파이프라인</h3><span class="fd-tick" id="pipeTotal">총 —</span></div>
|
|
||||||
<div class="fd-pb" id="funnel"><div class="fd-loading">불러오는 중…</div></div>
|
|
||||||
<div class="fd-ph" style="border-top:1.5px solid var(--fd-rule-strong)"><h3 style="font-size:13px">출처 · 포맷</h3><a class="fd-tick" th:href="@{/collection}">수집함 →</a></div>
|
|
||||||
<div class="fd-pb" id="srcFmt"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="md-columns" aria-hidden="true"><span>순위 / 영상</span><span>업로드</span><span>조회수</span><span>시간당</span></div>
|
||||||
|
<div id="materialList" class="md-list"><div class="md-empty">소재 데이터를 불러오는 중입니다.</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
<footer class="md-foot">
|
||||||
|
<span>조회수 현행화는 YouTube videos.list를 50개씩 묶어 호출합니다.</span>
|
||||||
<!-- 영상 미리보기 모달 -->
|
<a href="/board">선택한 소재 검토하기 →</a>
|
||||||
<div id="videoModal" class="fd-vmodal" onclick="if(event.target===this) closeVideoModal()">
|
</footer>
|
||||||
<div class="fd-vmodal-card">
|
</main>
|
||||||
<div class="fd-vmodal-head">
|
|
||||||
<span class="fd-vmodal-title" id="vmTitle">미리보기</span>
|
|
||||||
<button class="fd-vmodal-x" onclick="closeVideoModal()" aria-label="닫기">×</button>
|
|
||||||
</div>
|
|
||||||
<div id="vmFrame" class="fd-vmodal-frame"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ===== Editorial identity — scoped to .fd-dash ===== */
|
.md-desk{--navy:#102a43;--blue:#176b87;--cyan:#34b3c0;--ice:#e8f4f5;--paper:#f7faf9;--line:#ccdadd;--ink:#16252d;--muted:#6c7d83;margin:-2.5rem;min-height:100vh;padding:34px;background:linear-gradient(125deg,#f8fbfa 0%,#edf5f5 62%,#e4f0f2 100%);color:var(--ink);font-family:'Hanken Grotesk',sans-serif}
|
||||||
.fd-dash{
|
:root[data-theme="dark"] .md-desk{--paper:#142228;--line:#30434a;--ink:#edf6f6;--muted:#92a6ac;--ice:#17343c;background:#0e1a20}
|
||||||
--fd-paper:#f1ede3; --fd-panel:#fbfaf6; --fd-ink:#17160f; --fd-ink-2:#6f6a5c; --fd-ink-3:#a8a293;
|
.md-head{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:24px;border-bottom:3px solid var(--navy)}
|
||||||
--fd-rule:#ddd7c8; --fd-rule-strong:#17160f; --fd-signal:#ff4d23; --fd-signal-soft:#ffe6df; --fd-lime:#1f7a3a;
|
.md-kicker{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.16em;color:var(--blue)}
|
||||||
--fd-disp:'Bricolage Grotesque', sans-serif; --fd-body:'Hanken Grotesk', sans-serif; --fd-mono:'JetBrains Mono', monospace;
|
.md-head h1{font-family:'Bricolage Grotesque',sans-serif;font-size:42px;line-height:1;margin:8px 0 6px;letter-spacing:-.04em}.md-head p{color:var(--muted);margin:0}
|
||||||
font-family:var(--fd-body); color:var(--fd-ink);
|
.md-actions{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted)}
|
||||||
margin:-2.5rem; padding:0; min-height:100vh; background:var(--fd-paper); position:relative;
|
.md-btn{border:1px solid var(--navy);background:var(--navy);color:white;padding:9px 13px;font-weight:700;cursor:pointer}.md-btn.ghost{background:transparent;color:var(--ink)}.md-btn:disabled{opacity:.5;cursor:wait}
|
||||||
}
|
.md-score{display:grid;grid-template-columns:1.45fr repeat(4,1fr);margin:24px 0;border:1px solid var(--navy);background:var(--paper)}
|
||||||
:root[data-theme="dark"] .fd-dash{
|
.md-score>a{text-decoration:none;color:inherit}.md-score-main,.md-metric{padding:20px;border-right:1px solid var(--line);display:flex;flex-direction:column}.md-metric:last-child{border-right:0}.md-score-main{background:var(--navy);color:white}.md-score span{font-size:12px;font-weight:700}.md-score strong{font-family:'Bricolage Grotesque',sans-serif;font-size:38px;line-height:1;margin:12px 0 7px}.md-score small{color:var(--muted)}.md-score-main small{color:#bdd3dc}.md-metric.hot strong{color:#e85636}
|
||||||
--fd-paper:#100f0c; --fd-panel:#191712; --fd-ink:#eceae2; --fd-ink-2:#a39d8d; --fd-ink-3:#6b6657;
|
.md-board{border:1px solid var(--navy);background:var(--paper)}.md-board-head{display:flex;justify-content:space-between;align-items:flex-end;padding:18px 20px;border-bottom:1px solid var(--navy)}.md-board-head h2{font-size:20px;margin:5px 0 0}.md-tabs{display:flex;gap:14px}.md-tabs a{font-size:12px;font-weight:700;color:var(--blue)}
|
||||||
--fd-rule:#2b281f; --fd-rule-strong:#3a362a;
|
.md-columns,.md-row{display:grid;grid-template-columns:minmax(320px,1fr) 110px 110px 110px;gap:14px;align-items:center}.md-columns{padding:8px 20px;background:var(--ice);font:10px 'JetBrains Mono',monospace;color:var(--muted);text-transform:uppercase}.md-row{padding:12px 20px;border-top:1px solid var(--line);text-decoration:none;color:inherit}.md-row:hover{background:var(--ice)}
|
||||||
}
|
.md-video{display:grid;grid-template-columns:28px 96px minmax(0,1fr);gap:12px;align-items:center}.md-rank{font:800 20px 'Bricolage Grotesque',sans-serif;color:var(--blue)}.md-thumb{width:96px;aspect-ratio:16/9;object-fit:cover}.md-title{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.md-sub{font-size:11px;color:var(--muted);margin-top:4px}.md-cell{font:12px 'JetBrains Mono',monospace}.md-speed{font-weight:800;color:var(--blue)}.md-tag{display:inline-block;padding:2px 6px;margin-right:6px;background:var(--ice);color:var(--blue);font-size:10px;font-weight:800}.md-tag.politics{background:#f4e9e5;color:#a1442e}
|
||||||
.fd-dash::before{ content:""; position:absolute; inset:0; pointer-events:none; opacity:.05;
|
.md-empty{padding:36px;text-align:center;color:var(--muted)}.md-foot{display:flex;justify-content:space-between;padding:14px 2px;color:var(--muted);font-size:11px}.md-foot a{color:var(--blue);font-weight:700}
|
||||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
|
@media(max-width:900px){.md-desk{margin:-1.25rem;padding:18px}.md-head{align-items:flex-start;gap:18px}.md-actions{flex-wrap:wrap;justify-content:flex-end}.md-score{grid-template-columns:repeat(2,1fr)}.md-score-main{grid-column:1/-1}.md-metric{border-top:1px solid var(--line)}.md-columns{display:none}.md-row{grid-template-columns:1fr 90px}.md-row .md-cell:nth-child(2),.md-row .md-cell:nth-child(3){display:none}}
|
||||||
.fd-dash a{ text-decoration:none; color:inherit; }
|
@media(max-width:600px){.md-head{display:block}.md-head h1{font-size:34px}.md-actions{margin-top:16px;justify-content:flex-start}.md-score{grid-template-columns:1fr 1fr}.md-video{grid-template-columns:24px 72px minmax(0,1fr)}.md-thumb{width:72px}.md-row{padding:11px}.md-foot{display:block}.md-foot a{display:block;margin-top:8px}}
|
||||||
.fd-tick{ font-family:var(--fd-mono); font-size:10.5px; font-weight:500; letter-spacing:.18em; text-transform:uppercase; color:var(--fd-ink-3); }
|
|
||||||
|
|
||||||
.fd-masthead{ display:flex; justify-content:space-between; align-items:flex-end; padding:24px 34px 16px; border-bottom:1.5px solid var(--fd-rule-strong); position:relative; }
|
|
||||||
.fd-masthead h1{ font-family:var(--fd-disp); font-weight:800; font-size:32px; letter-spacing:-.025em; line-height:.95; }
|
|
||||||
.fd-meta{ text-align:right; }
|
|
||||||
.fd-meta .fd-big{ font-family:var(--fd-mono); font-weight:700; font-size:13px; }
|
|
||||||
|
|
||||||
.fd-body{ padding:24px 34px 40px; position:relative; }
|
|
||||||
.fd-stagger > *{ opacity:0; transform:translateY(10px); animation:fdRise .7s cubic-bezier(.2,.7,.2,1) forwards; }
|
|
||||||
@keyframes fdRise{ to{ opacity:1; transform:none; } }
|
|
||||||
@media (prefers-reduced-motion: reduce){ .fd-stagger > *{ animation:none; opacity:1; transform:none; } }
|
|
||||||
|
|
||||||
.fd-btn{ display:inline-flex; align-items:center; gap:7px; font-family:var(--fd-mono); font-weight:700; font-size:11.5px; letter-spacing:.04em; padding:8px 13px; border:1.5px solid var(--fd-rule-strong); background:var(--fd-panel); color:var(--fd-ink); cursor:pointer; }
|
|
||||||
.fd-btn:hover{ background:var(--fd-ink); color:var(--fd-paper); }
|
|
||||||
.fd-btn.fd-sig{ background:var(--fd-signal); border-color:var(--fd-signal); color:#fff; }
|
|
||||||
.fd-btn.fd-sig:hover{ background:#e23c14; color:#fff; }
|
|
||||||
.fd-btn.fd-sm{ padding:5px 9px; font-size:10.5px; }
|
|
||||||
|
|
||||||
.fd-actions{ display:grid; grid-template-columns:1.3fr 1fr 1fr; border:1.5px solid var(--fd-rule-strong); background:var(--fd-panel); margin-bottom:22px; }
|
|
||||||
.fd-act{ padding:20px 22px; border-right:1.5px solid var(--fd-rule-strong); display:flex; flex-direction:column; gap:12px; position:relative; overflow:hidden; }
|
|
||||||
.fd-act:last-child{ border-right:none; }
|
|
||||||
.fd-act:hover{ background:#f3eee2; }
|
|
||||||
:root[data-theme="dark"] .fd-act:hover{ background:#211e17; }
|
|
||||||
.fd-act-top{ display:flex; align-items:baseline; gap:12px; }
|
|
||||||
.fd-num{ font-family:var(--fd-disp); font-weight:800; font-size:60px; line-height:.8; letter-spacing:-.04em; font-variant-numeric:tabular-nums; }
|
|
||||||
.fd-act-lab .t{ font-weight:700; font-size:14px; }
|
|
||||||
.fd-act-lab .s{ font-family:var(--fd-mono); font-size:10.5px; color:var(--fd-ink-3); margin-top:3px; }
|
|
||||||
.fd-hot::after{ content:""; position:absolute; right:-60px; top:-60px; width:200px; height:200px; border-radius:50%; background:radial-gradient(circle, rgba(255,77,35,.14), transparent 62%); }
|
|
||||||
.fd-cta-row{ display:flex; gap:8px; z-index:1; }
|
|
||||||
.fd-muted-cta{ font-family:var(--fd-mono); font-size:10.5px; color:var(--fd-ink-3); line-height:1.5; }
|
|
||||||
|
|
||||||
.fd-cols{ display:grid; grid-template-columns:1.55fr 1fr; gap:22px; align-items:start; }
|
|
||||||
.fd-panel{ border:1.5px solid var(--fd-rule-strong); background:var(--fd-panel); }
|
|
||||||
.fd-ph{ display:flex; justify-content:space-between; align-items:center; padding:13px 20px; border-bottom:1.5px solid var(--fd-rule-strong); }
|
|
||||||
.fd-ph h3{ font-family:var(--fd-disp); font-weight:700; font-size:15px; letter-spacing:-.01em; }
|
|
||||||
.fd-pb{ padding:4px 20px 12px; }
|
|
||||||
.fd-loading{ font-family:var(--fd-mono); font-size:11px; color:var(--fd-ink-3); padding:20px 0; text-align:center; }
|
|
||||||
|
|
||||||
.fd-lead{ display:grid; grid-template-columns:30px 52px 1fr auto auto; gap:13px; align-items:center; padding:11px 0; border-bottom:1px solid var(--fd-rule); }
|
|
||||||
.fd-lead:last-child{ border-bottom:none; }
|
|
||||||
.fd-lead:hover{ background:#f3eee2; }
|
|
||||||
:root[data-theme="dark"] .fd-lead:hover{ background:#211e17; }
|
|
||||||
.fd-rk{ font-family:var(--fd-disp); font-weight:800; font-size:23px; line-height:1; font-variant-numeric:tabular-nums; }
|
|
||||||
.fd-lead:first-child .fd-rk{ color:var(--fd-signal); }
|
|
||||||
.fd-lead .fd-th{ width:52px; height:30px; object-fit:cover; background:#e7e1d2; border:1px solid var(--fd-rule); }
|
|
||||||
.fd-lead .fd-t{ font-weight:600; font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:230px; }
|
|
||||||
.fd-lead .fd-s{ font-family:var(--fd-mono); font-size:10px; color:var(--fd-ink-3); margin-top:2px; }
|
|
||||||
.fd-ratio{ font-family:var(--fd-disp); font-weight:800; font-size:17px; color:var(--fd-signal); font-variant-numeric:tabular-nums; }
|
|
||||||
.fd-actcell{ display:flex; gap:6px; opacity:.4; transition:opacity .15s; }
|
|
||||||
.fd-lead:hover .fd-actcell{ opacity:1; }
|
|
||||||
|
|
||||||
.fd-fbar{ margin-bottom:11px; }
|
|
||||||
.fd-fbar .top{ display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px; }
|
|
||||||
.fd-fbar .top .k{ font-size:12px; font-weight:600; }
|
|
||||||
.fd-fbar .top .v{ font-family:var(--fd-mono); font-size:11px; color:var(--fd-ink-2); }
|
|
||||||
.fd-fbar .track{ height:8px; background:var(--fd-rule); border:1px solid var(--fd-rule); position:relative; }
|
|
||||||
.fd-fbar .track > i{ position:absolute; left:0; top:0; bottom:0; background:var(--fd-ink); }
|
|
||||||
.fd-fbar.fd-sigbar .track > i{ background:var(--fd-signal); }
|
|
||||||
.fd-bottleneck{ display:flex; justify-content:space-between; align-items:center; margin-top:12px; padding-top:12px; border-top:1px dashed var(--fd-rule-strong); }
|
|
||||||
.fd-bottleneck .msg{ font-family:var(--fd-mono); font-size:10.5px; color:var(--fd-ink-2); }
|
|
||||||
.fd-bottleneck .msg b{ color:var(--fd-signal); }
|
|
||||||
|
|
||||||
.fd-row{ display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--fd-rule); font-size:12.5px; }
|
|
||||||
.fd-row:last-child{ border-bottom:none; }
|
|
||||||
.fd-row .v{ font-family:var(--fd-mono); color:var(--fd-ink-2); }
|
|
||||||
.fd-row .v b{ color:var(--fd-ink); }
|
|
||||||
.fd-empty{ font-family:var(--fd-mono); font-size:11px; color:var(--fd-ink-3); padding:14px 0; }
|
|
||||||
|
|
||||||
/* 영상 미리보기 모달 */
|
|
||||||
.fd-vmodal{ display:none; position:fixed; inset:0; z-index:1000; background:rgba(10,10,12,.86); align-items:center; justify-content:center; padding:20px; }
|
|
||||||
.fd-vmodal.open{ display:flex; }
|
|
||||||
.fd-vmodal-card{ background:var(--fd-panel); border:1.5px solid var(--fd-rule-strong); box-shadow:0 20px 60px rgba(0,0,0,.4); }
|
|
||||||
.fd-vmodal-head{ display:flex; justify-content:space-between; align-items:center; gap:14px; padding:11px 14px; border-bottom:1.5px solid var(--fd-rule-strong); }
|
|
||||||
.fd-vmodal-title{ font-weight:600; font-size:13px; color:var(--fd-ink); max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.fd-vmodal-x{ background:none; border:none; font-size:24px; line-height:1; cursor:pointer; color:var(--fd-ink-2); padding:0 2px; }
|
|
||||||
.fd-vmodal-x:hover{ color:var(--fd-ink); }
|
|
||||||
.fd-vmodal-frame{ width:min(360px,90vw); aspect-ratio:9/16; max-height:78vh; background:#000; }
|
|
||||||
.fd-vmodal-frame iframe{ width:100%; height:100%; border:0; display:block; }
|
|
||||||
|
|
||||||
@media (max-width:1100px){ .fd-cols{ grid-template-columns:1fr; } .fd-actions{ grid-template-columns:1fr; } .fd-act{ border-right:none; border-bottom:1.5px solid var(--fd-rule-strong); } }
|
|
||||||
@media (max-width:680px){
|
|
||||||
.fd-dash{ margin:-1.25rem; }
|
|
||||||
.fd-masthead{ flex-direction:column; align-items:stretch; gap:8px; padding:16px 16px 12px; }
|
|
||||||
.fd-masthead .fd-tick{ display:none; } /* 긴 브레드크럼/라벨 숨김 */
|
|
||||||
.fd-masthead h1{ font-size:26px; }
|
|
||||||
.fd-meta{ display:flex; align-items:center; gap:12px; }
|
|
||||||
.fd-meta .fd-big{ margin:0; }
|
|
||||||
.fd-body{ padding:16px 16px 28px; }
|
|
||||||
.fd-num{ font-size:44px; }
|
|
||||||
.fd-act{ padding:16px 16px; }
|
|
||||||
/* 리더보드 행: 배율까지 한 줄, 액션 버튼은 아랫줄 우측 정렬 */
|
|
||||||
.fd-lead{ grid-template-columns:24px 46px 1fr auto; column-gap:10px; }
|
|
||||||
.fd-lead .fd-actcell{ grid-column:1 / -1; justify-content:flex-end; opacity:1; padding-top:2px; }
|
|
||||||
.fd-lead .fd-t{ white-space:normal; max-width:none; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script th:inline="javascript">
|
<script>
|
||||||
/*<![CDATA[*/
|
const fmt=n=>Number(n||0).toLocaleString();
|
||||||
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
const esc=s=>String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
function fmt(n){ return (n==null)?'0':Number(n).toLocaleString(); }
|
const ago=iso=>{if(!iso)return'-';const h=Math.max(0,Math.floor((Date.now()-new Date(iso))/36e5));return h<24?h+'시간 전':Math.floor(h/24)+'일 전'};
|
||||||
function pct(n,t){ return t>0?Math.round((Number(n||0)/t)*100):0; }
|
async function api(url,opts){const r=await fetch(url,opts);const j=await r.json();if(!r.ok||j.success===false)throw new Error(j.message||('HTTP '+r.status));return j.data}
|
||||||
|
function renderRow(v,i){const topic=v.topic==='POLITICS'?'정치':'예능';const cls=v.topic==='POLITICS'?' politics':'';const href=v.topic==='POLITICS'?'/politics-feed':'/feed';return `<a class="md-row" href="${href}"><div class="md-video"><span class="md-rank">${String(i+1).padStart(2,'0')}</span><img class="md-thumb" src="${esc(v.thumbnailUrl)}" alt=""><div><div class="md-title">${esc(v.title)}</div><div class="md-sub"><span class="md-tag${cls}">${topic}</span>${esc(v.channelTitle||'')} ${v.goldenTime?'· 지금 선점':''}</div></div></div><span class="md-cell">${ago(v.publishedAt)}</span><span class="md-cell">${fmt(v.viewCount)}</span><span class="md-cell md-speed">${fmt(Math.round(Number(v.viewsPerHour||0)))}/h</span></a>`}
|
||||||
async function getData(url, opts){
|
async function loadDashboard(){try{const d=await api('/api/dashboard/summary');const f=d.feedDesk||{};sUnworked.textContent=fmt(f.unworked);sGolden.textContent=fmt(f.golden);sEntertainment.textContent=fmt(f.entertainment);sPolitics.textContent=fmt(f.politics);sBookmarked.textContent=fmt(f.bookmarked);materialList.innerHTML=(f.top||[]).length?(f.top||[]).map(renderRow).join(''):'<div class="md-empty">최근 14일 소재가 없습니다. 소재 피드에서 수집을 실행하세요.</div>';updatedAt.textContent='화면 갱신 '+new Date().toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit'});}catch(e){materialList.innerHTML='<div class="md-empty">불러오기 실패 · '+esc(e.message)+'</div>'}}
|
||||||
const r = await fetch(url, opts);
|
async function refreshViews(){const b=refreshViewsBtn;b.disabled=true;b.textContent='조회수 갱신 중';try{const [e,p]=await Promise.all([api('/api/feed/refresh-views?topic=ENTERTAINMENT',{method:'POST'}),api('/api/feed/refresh-views?topic=POLITICS',{method:'POST'})]);updatedAt.textContent=`${fmt(e.updated+p.updated)}개 갱신 완료`;await loadDashboard()}catch(err){alert('조회수 갱신 실패: '+err.message)}finally{b.disabled=false;b.textContent='조회수 현행화'}}
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
(function(){ const d=new Date(); const p=n=>String(n).padStart(2,'0');
|
|
||||||
document.getElementById('fdDate').textContent = d.getFullYear()+' · '+p(d.getMonth()+1)+' · '+p(d.getDate()); })();
|
|
||||||
|
|
||||||
function fbar(label, count, total, signal){
|
|
||||||
const p = pct(count,total);
|
|
||||||
return `<div class="fd-fbar ${signal?'fd-sigbar':''}"><div class="top"><span class="k">${esc(label)}</span><span class="v">${fmt(count)} · ${p}%</span></div><div class="track"><i style="width:${p}%"></i></div></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDashboard(){
|
|
||||||
let d;
|
|
||||||
try { d = await getData('/api/dashboard/summary'); }
|
|
||||||
catch(e){ document.getElementById('leadList').innerHTML = '<div class="fd-empty" style="color:var(--fd-signal)">불러오기 실패: '+esc(e.message)+'</div>'; return; }
|
|
||||||
|
|
||||||
const pipe = d.pipeline||{}, bs = pipe.byStatus||{}, src = pipe.bySource||{};
|
|
||||||
const total = Number(pipe.total||0);
|
|
||||||
const op = d.outperformers||[];
|
|
||||||
|
|
||||||
// ----- 액션 바 -----
|
|
||||||
document.getElementById('aNew').textContent = fmt(bs.NEW);
|
|
||||||
document.getElementById('aNewSub').textContent = 'NEW · 검토 대기' + (bs.REVIEWING?(' · 검토중 '+fmt(bs.REVIEWING)):'');
|
|
||||||
document.getElementById('aOut').textContent = op.length ? fmt(op.length) : '0';
|
|
||||||
document.getElementById('aOutSub').textContent = op.length ? ('배율 상위 · 최고 '+(op[0].viewsPerSubRatio!=null?Number(op[0].viewsPerSubRatio).toFixed(0)+'×':'-')) : '아직 없음';
|
|
||||||
document.getElementById('aTarget').textContent = fmt(bs.TARGET);
|
|
||||||
document.getElementById('aTargetCta').textContent = Number(bs.TARGET||0)>0 ? '재가공 작업공간에서 이어서 →' : '발굴에서 후보를 TARGET으로 보내세요 →';
|
|
||||||
|
|
||||||
// ----- 떡상 리더보드 (실데이터 + 액션) -----
|
|
||||||
const lead = document.getElementById('leadList');
|
|
||||||
if(op.length===0){ lead.innerHTML = '<div class="fd-empty">아직 떡상 후보가 없습니다. 채널을 수집하면 배율 상위 영상이 여기 모입니다.</div>'; }
|
|
||||||
else {
|
|
||||||
lead.innerHTML = op.slice(0,6).map((v,i)=>{
|
|
||||||
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(0)+'×' : '–';
|
|
||||||
return `<div class="fd-lead" data-id="${v.id}">
|
|
||||||
<div class="fd-rk">${i+1}</div>
|
|
||||||
<img class="fd-th" src="${esc(v.thumbnailUrl)}" alt="" title="미리보기" style="cursor:pointer;"
|
|
||||||
data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">
|
|
||||||
<div style="min-width:0;">
|
|
||||||
<div class="fd-t"><a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank">${esc(v.title)}</a></div>
|
|
||||||
<div class="fd-s">SHORTS · ${esc(v.channelTitle||'')} · ${fmt(v.viewCount)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="fd-ratio">${ratio}</div>
|
|
||||||
<div class="fd-actcell">
|
|
||||||
<a class="fd-btn fd-sm fd-sig" href="/rework/${v.id}">재가공 →</a>
|
|
||||||
<button class="fd-btn fd-sm" onclick="excludeVideo(${v.id})">제외</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 파이프라인 + 병목 -----
|
|
||||||
document.getElementById('pipeTotal').textContent = '총 ' + fmt(total);
|
|
||||||
const stages = [
|
|
||||||
['미검토', bs.NEW], ['검토중', bs.REVIEWING], ['작업대상', bs.TARGET],
|
|
||||||
['완료', bs.DONE]
|
|
||||||
];
|
|
||||||
let bottleneck = stages[0];
|
|
||||||
for(const s of stages){ if(Number(s[1]||0) > Number(bottleneck[1]||0)) bottleneck = s; }
|
|
||||||
document.getElementById('funnel').innerHTML =
|
|
||||||
fbar('미검토', bs.NEW, total, true) +
|
|
||||||
fbar('검토중', bs.REVIEWING, total) +
|
|
||||||
fbar('작업대상', bs.TARGET, total) +
|
|
||||||
fbar('완료', bs.DONE, total) +
|
|
||||||
`<div class="fd-bottleneck"><div class="msg">병목: <b>${esc(bottleneck[0])} ${fmt(bottleneck[1])}</b> — 다음 단계로 옮겨보세요</div><a class="fd-btn fd-sm fd-sig" href="/board">칸반 보드</a></div>`;
|
|
||||||
|
|
||||||
// ----- 출처/포맷 -----
|
|
||||||
document.getElementById('srcFmt').innerHTML =
|
|
||||||
`<div class="fd-row"><span>채널 수집</span><span class="v"><b>${fmt(src.CHANNEL)}</b> · ${pct(src.CHANNEL,total)}%</span></div>` +
|
|
||||||
`<div class="fd-row"><span>검색 수집</span><span class="v"><b>${fmt(src.SEARCH)}</b> · ${pct(src.SEARCH,total)}%</span></div>` +
|
|
||||||
`<div class="fd-row"><span>Shorts</span><span class="v"><b>${fmt(pipe.shorts)}</b> · ${pct(pipe.shorts,total)}%</span></div>`;
|
|
||||||
|
|
||||||
if(window.lucide) lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function excludeVideo(id){
|
|
||||||
if(!confirm('이 영상을 제외(EXCLUDED) 처리할까요? 떡상 후보에서 사라집니다.')) return;
|
|
||||||
try {
|
|
||||||
await getData('/api/v1/channel-videos/'+id+'/status', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({status:'EXCLUDED'}) });
|
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
} catch(e){ alert('제외 실패: '+e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 영상 미리보기 (수집함과 동일) -----
|
|
||||||
function openVideoModal(vid, title){
|
|
||||||
document.getElementById('vmTitle').textContent = title || '미리보기';
|
|
||||||
document.getElementById('vmFrame').innerHTML =
|
|
||||||
'<iframe src="https://www.youtube.com/embed/' + vid + '?autoplay=1" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
|
|
||||||
document.getElementById('videoModal').classList.add('open');
|
|
||||||
}
|
|
||||||
function closeVideoModal(){
|
|
||||||
document.getElementById('vmFrame').innerHTML = '';
|
|
||||||
document.getElementById('videoModal').classList.remove('open');
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
|
|
||||||
|
|
||||||
loadDashboard();
|
|
||||||
/*]]>*/
|
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -21,6 +21,9 @@
|
|||||||
<button class="btn btn-secondary" onclick="openSeeds()">
|
<button class="btn btn-secondary" onclick="openSeeds()">
|
||||||
<i data-lucide="list-plus" style="width:15px;"></i> 시드 관리
|
<i data-lucide="list-plus" style="width:15px;"></i> 시드 관리
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary" id="refreshViewsBtn" onclick="refreshViews()">
|
||||||
|
<i data-lucide="bar-chart-3" style="width:15px;"></i> 조회수 현행화
|
||||||
|
</button>
|
||||||
<button class="btn btn-primary" id="collectBtn" onclick="runCollect()">
|
<button class="btn btn-primary" id="collectBtn" onclick="runCollect()">
|
||||||
<i data-lucide="refresh-cw" style="width:15px;"></i> 지금 수집
|
<i data-lucide="refresh-cw" style="width:15px;"></i> 지금 수집
|
||||||
</button>
|
</button>
|
||||||
@ -806,6 +809,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 수집 ----------
|
// ---------- 수집 ----------
|
||||||
|
async function refreshViews(){
|
||||||
|
const btn = document.getElementById('refreshViewsBtn');
|
||||||
|
const original = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i data-lucide="loader-2" style="width:15px;" class="animate-spin"></i> 갱신 중...';
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
try {
|
||||||
|
const r = await api(API + '/refresh-views?topic=' + FEED_TOPIC, { method:'POST' });
|
||||||
|
toast(`조회수 현행화 완료 — ${r.updated}개 · 쿼터 ${r.quotaUsed} units`);
|
||||||
|
await loadFeed();
|
||||||
|
} catch(e) {
|
||||||
|
toast('조회수 현행화 실패: ' + e.message, true);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = original;
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runCollect(){
|
async function runCollect(){
|
||||||
const btn = document.getElementById('collectBtn');
|
const btn = document.getElementById('collectBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.hlab.yanalyst.service;
|
|||||||
import com.hlab.yanalyst.domain.category.CategoryService;
|
import com.hlab.yanalyst.domain.category.CategoryService;
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelVideo;
|
import com.hlab.yanalyst.domain.channel.ChannelVideo;
|
||||||
import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService;
|
import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService;
|
||||||
|
import com.hlab.yanalyst.domain.channel.FeedService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
@ -25,6 +26,7 @@ class DashboardServiceTest {
|
|||||||
|
|
||||||
@Mock ChannelVideoCurationService curationService;
|
@Mock ChannelVideoCurationService curationService;
|
||||||
@Mock CategoryService categoryService;
|
@Mock CategoryService categoryService;
|
||||||
|
@Mock FeedService feedService;
|
||||||
|
|
||||||
@InjectMocks DashboardService dashboardService;
|
@InjectMocks DashboardService dashboardService;
|
||||||
|
|
||||||
@ -37,12 +39,15 @@ class DashboardServiceTest {
|
|||||||
when(curationService.pipelineStats()).thenReturn(pipeline);
|
when(curationService.pipelineStats()).thenReturn(pipeline);
|
||||||
when(categoryService.distribution()).thenReturn(categories);
|
when(categoryService.distribution()).thenReturn(categories);
|
||||||
when(curationService.findOutperformers(5, BigDecimal.ONE)).thenReturn(outperformers);
|
when(curationService.findOutperformers(5, BigDecimal.ONE)).thenReturn(outperformers);
|
||||||
|
when(feedService.feed("SOURCE", "ENTERTAINMENT", 14, null, null, false, null)).thenReturn(List.of());
|
||||||
|
when(feedService.feed("SOURCE", "POLITICS", 14, null, null, false, null)).thenReturn(List.of());
|
||||||
|
|
||||||
Map<String, Object> result = dashboardService.summary();
|
Map<String, Object> result = dashboardService.summary();
|
||||||
|
|
||||||
assertThat(result).containsOnlyKeys("pipeline", "categories", "outperformers");
|
assertThat(result).containsOnlyKeys("pipeline", "categories", "outperformers", "feedDesk");
|
||||||
assertThat(result.get("pipeline")).isSameAs(pipeline);
|
assertThat(result.get("pipeline")).isSameAs(pipeline);
|
||||||
assertThat(result.get("categories")).isSameAs(categories);
|
assertThat(result.get("categories")).isSameAs(categories);
|
||||||
assertThat(result.get("outperformers")).isSameAs(outperformers);
|
assertThat(result.get("outperformers")).isSameAs(outperformers);
|
||||||
|
assertThat(result.get("feedDesk")).isInstanceOf(Map.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user