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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 리스트 본문 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user