fix(ui): 배포 전 적대 리뷰 반영 — 대시보드/문서/뉴스

15-에이전트 적대 리뷰의 확정 결함 수정:
- dashboard: digest 헤드라인 날짜 d.date→d.digest_date ("undefined 브리핑" 버그/HIGH)
  + 빠른캡처 후 refresh() + 스탯띠 nowrap(줄바꿈 구분선 제거) + formatTime Invalid 가드 + chevron :global
- documents: bulkAddTag 검색모드 데이터손실 방지(태그 미확인 시 풀문서 머지/HIGH)
  + selectDoc 풀 하이드레이션(인스펙터 메타 보강) + 검색모드 클라정렬 비활성 + 죽은 handleDocDelete 제거
- news: 인용 출처 국가 색칩 추가(+빈 국가 가드) + 읽음 스탬프(시안 충실)
digest/memos = 확정 결함 0(무변). vite build PASS·토큰 청결.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-07 20:10:43 +09:00
parent 126f633d32
commit a1a46f2a2b
3 changed files with 46 additions and 22 deletions
+6 -3
View File
@@ -5,6 +5,7 @@
import { onMount } from 'svelte';
import {
dashboardSummary,
refresh,
type DashboardSummary,
type PipelineStatus,
type QueueLag,
@@ -67,6 +68,7 @@
await api('/memos/', { method: 'POST', body: JSON.stringify({ content }) });
captureText = '';
addToast('success', '메모 저장됨');
void refresh(); // 메모 수 등 요약 즉시 갱신(60s 폴 기다리지 않음)
} catch {
addToast('error', '메모 저장 실패');
} finally {
@@ -103,7 +105,7 @@
article_count: topics[0].article_count,
importance_score: topics[0].importance_score,
country: topics[0].country,
date: d.date,
date: d.digest_date,
};
}
} catch { /* 디제스트 없으면 블록 자동 생략 */ }
@@ -182,6 +184,7 @@
function formatTime(dateStr: string) {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return ''; // 빈 문자열/유효하지 않은 created_at → 'Invalid Date' 회피
const diff = Date.now() - d.getTime();
if (diff < 60000) return '방금';
if (diff < 3600000) return `${Math.floor(diff / 60000)}분 전`;
@@ -262,7 +265,7 @@
</div>
<!-- 스탯 띠 -->
<div class="flex flex-wrap gap-y-3 border-t border-default mt-4 pt-4">
<div class="flex flex-nowrap overflow-x-auto border-t border-default mt-4 pt-4">
{@render stat((summary.documents_count ?? 0).toLocaleString(), '문서', 'text-accent')}
{@render stat((summary.news_count ?? 0).toLocaleString(), '뉴스')}
{#if domainTotal > 0}
@@ -480,6 +483,6 @@
{/snippet}
<style>
details[open] .details-chevron { transform: rotate(90deg); }
details[open] :global(.details-chevron) { transform: rotate(90deg); }
details summary::-webkit-details-marker { display: none; }
</style>
+33 -18
View File
@@ -231,8 +231,15 @@
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
}
function selectDoc(doc) {
selectedDoc = selectedDoc?.id === doc.id ? null : doc;
async function selectDoc(doc) {
if (selectedDoc?.id === doc.id) { selectedDoc = null; return; }
selectedDoc = doc; // 즉시 표시(리더 + 기본 인스펙터)
// 인스펙터 풀 메타 하이드레이션 — 검색 결과(SearchResult)는 메타가 빈약(태그/크기/하위/md상태/읽음 없음).
// 풀 문서를 조회해 채운다(기존 GET /documents/{id}, 백엔드 무변). 리스트 모드도 md상태 등 보강.
try {
const full = await api(`/documents/${doc.id}`);
if (selectedDoc?.id === doc.id) selectedDoc = { ...doc, ...full };
} catch { /* 실패 시 기본 정보 유지 */ }
}
// bulk 선택
@@ -271,7 +278,13 @@
if (!tag) return;
await runBulk('태그 추가', async (id) => {
const doc = sortedItems.find((d) => d.id === id);
const existing = Array.isArray(doc?.ai_tags) ? doc.ai_tags : [];
// ★ 검색 결과(SearchResult)는 ai_tags 가 없음 → 빈 배열로 PATCH 하면 기존 태그 전체 유실(덮어쓰기).
// 미확인이면 풀 문서를 조회해 실제 태그를 머지(데이터 손실 방지).
let existing = Array.isArray(doc?.ai_tags) ? doc.ai_tags : null;
if (existing == null) {
try { const full = await api(`/documents/${id}`); existing = Array.isArray(full?.ai_tags) ? full.ai_tags : []; }
catch { return; } // 현재 태그를 못 읽으면 덮어쓰지 않고 건너뜀
}
if (existing.includes(tag)) return;
return api(`/documents/${id}`, { method: 'PATCH', body: JSON.stringify({ ai_tags: [...existing, tag] }) });
});
@@ -282,12 +295,6 @@
await runBulk('삭제', (id) => api(`/documents/${id}?delete_file=true`, { method: 'DELETE' }));
}
function handleDocDelete() {
selectedDoc = null;
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
loadDocuments();
}
function handleKeydown(e) {
if (e.key === 'Escape') {
if (selectedDoc && !ui.isDrawerOpen('meta') && !isXl.current) { selectedDoc = null; return; }
@@ -298,7 +305,8 @@
let totalPages = $derived(Math.ceil(total / 50));
let items = $derived(searchResults || documents);
let sortedItems = $derived.by(() => {
if (!sortKey) return items;
// 검색 모드는 서버 관련도순 유지 + SearchResult 에 updated_at 부재(NaN 비교=무의미) → 클라 정렬 비활성
if (!sortKey || searchResults) return items;
const arr = [...items];
const dir = sortDir === 'asc' ? 1 : -1;
arr.sort((a, b) => {
@@ -497,14 +505,21 @@
<!-- 컬럼 헤더 + 결과 카운트 -->
<div class="flex items-center gap-2 px-3 py-1.5 shrink-0 border-b border-default bg-surface text-[10px] uppercase tracking-wide text-dim font-bold">
<button type="button" onclick={() => toggleSort('title')} class="flex items-center gap-1 hover:text-accent {sortKey === 'title' ? 'text-accent' : ''}">
제목 {#if sortKey === 'title'}<span>{sortDir === 'asc' ? '↑' : '↓'}</span>{:else}<ArrowUpDown size={10} class="opacity-50" />{/if}
</button>
<span class="flex-1"></span>
{#if !loading}<span class="text-faint normal-case font-normal tracking-normal">{total}</span>{/if}
<button type="button" onclick={() => toggleSort('updated')} class="flex items-center gap-1 hover:text-accent {sortKey === 'updated' ? 'text-accent' : ''}">
수정 {#if sortKey === 'updated'}<span>{sortDir === 'asc' ? '' : ''}</span>{/if}
</button>
{#if searchResults}
<!-- 검색 모드: 서버 관련도순(클라 정렬 비활성, SearchResult 메타 부재) -->
<span>관련도순</span>
<span class="flex-1"></span>
{#if !loading}<span class="text-faint normal-case font-normal tracking-normal">{total}</span>{/if}
{:else}
<button type="button" onclick={() => toggleSort('title')} class="flex items-center gap-1 hover:text-accent {sortKey === 'title' ? 'text-accent' : ''}">
제목 {#if sortKey === 'title'}<span>{sortDir === 'asc' ? '↑' : '↓'}</span>{:else}<ArrowUpDown size={10} class="opacity-50" />{/if}
</button>
<span class="flex-1"></span>
{#if !loading}<span class="text-faint normal-case font-normal tracking-normal">{total}</span>{/if}
<button type="button" onclick={() => toggleSort('updated')} class="flex items-center gap-1 hover:text-accent {sortKey === 'updated' ? 'text-accent' : ''}">
수정 {#if sortKey === 'updated'}<span>{sortDir === 'asc' ? '↑' : '↓'}</span>{/if}
</button>
{/if}
</div>
<!-- 리스트 본문 -->
+7 -1
View File
@@ -205,6 +205,9 @@
{isLead ? 'mt-4' : ''}
{topic.highlighted ? 'border-accent ring-2 ring-accent/25' : 'border-default'}
{topic.is_read ? 'opacity-50 hover:opacity-80' : ''}">
{#if topic.is_read}
<span class="absolute top-3 right-[88px] text-[10px] font-mono font-bold tracking-widest text-error border border-error rounded px-1.5 py-0.5 -rotate-6 opacity-70 pointer-events-none uppercase select-none">읽음</span>
{/if}
<!-- head -->
<div class="flex items-start gap-3.5 px-5 pt-4 pb-3.5 border-b border-default">
<div class="nws-serif font-extrabold leading-none text-text shrink-0 text-center pt-0.5 min-w-[42px]
@@ -273,7 +276,10 @@
{#each topic.key_quotes as q}
<div class="nws-quote relative pl-6">
<div class="nws-serif italic text-[15px] leading-snug text-text">{q.quote}</div>
<div class="text-[11px] text-dim font-mono mt-1 flex items-center gap-1.5"><span class="font-bold text-text">{countryLabel(q.country)} · {q.source}</span></div>
<div class="text-[11px] text-dim font-mono mt-1 flex items-center gap-1.5 flex-wrap">
{#if q.country}<span class="text-[9.5px] font-extrabold tracking-wide text-white rounded px-1.5 py-0.5 {countryChip(q.country)}">{countryLabel(q.country)}</span>{/if}
<span class="font-bold text-text">{q.source}</span>
</div>
</div>
{/each}
</div>