Phase 5: Add Timeline View feature
- Add backend API endpoints for timeline data:
- GET /api/v1/projects/{id}/timeline
- GET /api/v1/projects/{id}/timeline/summary
- GET /api/v1/entities/{id}/timeline
- Add database methods for timeline queries in db_manager.py
- Add frontend timeline view:
- New sidebar button for timeline view
- Timeline panel with date-grouped events
- Visual distinction between mentions and relations
- Entity filter dropdown
- Statistics cards
- Interactive event cards
- Update STATUS.md with Phase 5 progress
- Add view switching functions (switchView, switchKBTab)
- Add knowledge base loading functions
This commit is contained in:
357
frontend/app.js
357
frontend/app.js
@@ -752,3 +752,360 @@ function initUpload() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Phase 5: Timeline View ====================
|
||||
|
||||
async function loadTimeline() {
|
||||
const container = document.getElementById('timelineContainer');
|
||||
const entityFilter = document.getElementById('timelineEntityFilter');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '<div class="timeline-empty"><p>加载时间线数据...</p></div>';
|
||||
|
||||
try {
|
||||
// 更新实体筛选器选项
|
||||
if (entityFilter && projectEntities.length > 0) {
|
||||
const currentValue = entityFilter.value;
|
||||
entityFilter.innerHTML = '<option value="">全部实体</option>';
|
||||
projectEntities.forEach(ent => {
|
||||
const option = document.createElement('option');
|
||||
option.value = ent.id;
|
||||
option.textContent = ent.name;
|
||||
entityFilter.appendChild(option);
|
||||
});
|
||||
entityFilter.value = currentValue;
|
||||
}
|
||||
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams();
|
||||
if (entityFilter && entityFilter.value) {
|
||||
params.append('entity_id', entityFilter.value);
|
||||
}
|
||||
|
||||
// 获取时间线数据
|
||||
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/timeline?${params}`);
|
||||
if (!res.ok) throw new Error('Failed to load timeline');
|
||||
|
||||
const data = await res.json();
|
||||
const events = data.events || [];
|
||||
|
||||
// 更新统计
|
||||
const mentions = events.filter(e => e.type === 'mention').length;
|
||||
const relations = events.filter(e => e.type === 'relation').length;
|
||||
|
||||
document.getElementById('timelineTotalEvents').textContent = events.length;
|
||||
document.getElementById('timelineMentions').textContent = mentions;
|
||||
document.getElementById('timelineRelations').textContent = relations;
|
||||
|
||||
// 渲染时间线
|
||||
renderTimeline(events);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Load timeline failed:', err);
|
||||
container.innerHTML = '<div class="timeline-empty"><p style="color:#ff6b6b;">加载失败,请重试</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimeline(events) {
|
||||
const container = document.getElementById('timelineContainer');
|
||||
|
||||
if (events.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="timeline-empty">
|
||||
<p>暂无时间线数据</p>
|
||||
<p style="font-size:0.85rem;margin-top:8px;">请先上传音频或文档文件</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 按日期分组
|
||||
const grouped = groupEventsByDate(events);
|
||||
|
||||
let html = '<div class="timeline-line"></div>';
|
||||
|
||||
Object.entries(grouped).forEach(([date, dayEvents]) => {
|
||||
const dateLabel = formatDateLabel(date);
|
||||
|
||||
html += `
|
||||
<div class="timeline-date-group">
|
||||
<div class="timeline-date-header">
|
||||
<div class="timeline-date-label">${dateLabel}</div>
|
||||
<div class="timeline-date-dot"></div>
|
||||
</div>
|
||||
<div class="timeline-events">
|
||||
`;
|
||||
|
||||
dayEvents.forEach(event => {
|
||||
html += renderTimelineEvent(event);
|
||||
});
|
||||
|
||||
html += '</div></div>';
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function groupEventsByDate(events) {
|
||||
const grouped = {};
|
||||
|
||||
events.forEach(event => {
|
||||
const date = event.event_date.split('T')[0];
|
||||
if (!grouped[date]) {
|
||||
grouped[date] = [];
|
||||
}
|
||||
grouped[date].push(event);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (dateStr === today.toISOString().split('T')[0]) {
|
||||
return '今天';
|
||||
} else if (dateStr === yesterday.toISOString().split('T')[0]) {
|
||||
return '昨天';
|
||||
} else {
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimelineEvent(event) {
|
||||
if (event.type === 'mention') {
|
||||
return `
|
||||
<div class="timeline-event" onclick="selectEntity('${event.entity_id}')">
|
||||
<div class="timeline-event-header">
|
||||
<span class="timeline-event-type mention">提及</span>
|
||||
<span class="timeline-event-entity">${event.entity_name}</span>
|
||||
<span class="entity-type-badge type-${event.entity_type?.toLowerCase() || 'other'}" style="font-size:0.65rem;padding:1px 6px;">${event.entity_type || 'OTHER'}</span>
|
||||
</div>
|
||||
<div class="timeline-event-snippet">"${event.text_snippet || ''}"</div>
|
||||
<div class="timeline-event-source">
|
||||
<span>📄 ${event.source?.filename || '未知文件'}</span>
|
||||
${event.confidence ? `<span>置信度: ${(event.confidence * 100).toFixed(0)}%</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (event.type === 'relation') {
|
||||
return `
|
||||
<div class="timeline-event">
|
||||
<div class="timeline-event-header">
|
||||
<span class="timeline-event-type relation">关系</span>
|
||||
<span class="timeline-event-entity">${event.source_entity} → ${event.target_entity}</span>
|
||||
</div>
|
||||
<div style="color:#888;font-size:0.85rem;margin-bottom:8px;">
|
||||
关系类型: <span style="color:#00d4ff;">${event.relation_type}</span>
|
||||
</div>
|
||||
${event.evidence ? `<div class="timeline-event-snippet">"${event.evidence}"</div>` : ''}
|
||||
<div class="timeline-event-source">
|
||||
<span>📄 ${event.source?.filename || '未知文件'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ==================== View Switching ====================
|
||||
|
||||
window.switchView = function(viewName) {
|
||||
// 更新侧边栏按钮状态
|
||||
document.querySelectorAll('.sidebar-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// 隐藏所有视图
|
||||
document.getElementById('workbenchView').style.display = 'none';
|
||||
document.getElementById('knowledgeBaseView').classList.remove('show');
|
||||
document.getElementById('timelineView').classList.remove('show');
|
||||
|
||||
// 显示选中的视图
|
||||
if (viewName === 'workbench') {
|
||||
document.getElementById('workbenchView').style.display = 'flex';
|
||||
document.querySelector('.sidebar-btn:nth-child(1)').classList.add('active');
|
||||
} else if (viewName === 'knowledge-base') {
|
||||
document.getElementById('knowledgeBaseView').classList.add('show');
|
||||
document.querySelector('.sidebar-btn:nth-child(2)').classList.add('active');
|
||||
loadKnowledgeBase();
|
||||
} else if (viewName === 'timeline') {
|
||||
document.getElementById('timelineView').classList.add('show');
|
||||
document.querySelector('.sidebar-btn:nth-child(3)').classList.add('active');
|
||||
loadTimeline();
|
||||
}
|
||||
};
|
||||
|
||||
window.switchKBTab = function(tabName) {
|
||||
// 更新导航项状态
|
||||
document.querySelectorAll('.kb-nav-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// 隐藏所有部分
|
||||
document.querySelectorAll('.kb-section').forEach(section => {
|
||||
section.classList.remove('active');
|
||||
});
|
||||
|
||||
// 显示选中的部分
|
||||
const tabMap = {
|
||||
'entities': { nav: 0, section: 'kbEntitiesSection' },
|
||||
'relations': { nav: 1, section: 'kbRelationsSection' },
|
||||
'glossary': { nav: 2, section: 'kbGlossarySection' },
|
||||
'transcripts': { nav: 3, section: 'kbTranscriptsSection' }
|
||||
};
|
||||
|
||||
const mapping = tabMap[tabName];
|
||||
if (mapping) {
|
||||
document.querySelectorAll('.kb-nav-item')[mapping.nav].classList.add('active');
|
||||
document.getElementById(mapping.section).classList.add('active');
|
||||
}
|
||||
};
|
||||
|
||||
async function loadKnowledgeBase() {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/knowledge-base`);
|
||||
if (!res.ok) throw new Error('Failed to load knowledge base');
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// 更新统计
|
||||
document.getElementById('kbEntityCount').textContent = data.stats.entity_count;
|
||||
document.getElementById('kbRelationCount').textContent = data.stats.relation_count;
|
||||
document.getElementById('kbTranscriptCount').textContent = data.stats.transcript_count;
|
||||
document.getElementById('kbGlossaryCount').textContent = data.stats.glossary_count;
|
||||
|
||||
// 渲染实体网格
|
||||
const entityGrid = document.getElementById('kbEntityGrid');
|
||||
entityGrid.innerHTML = '';
|
||||
data.entities.forEach(ent => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'kb-entity-card';
|
||||
card.onclick = () => {
|
||||
switchView('workbench');
|
||||
setTimeout(() => selectEntity(ent.id), 100);
|
||||
};
|
||||
card.innerHTML = `
|
||||
<div class="kb-entity-name">
|
||||
<span class="entity-type-badge type-${ent.type.toLowerCase()}" style="font-size:0.65rem;margin-right:8px;">${ent.type}</span>
|
||||
${ent.name}
|
||||
</div>
|
||||
<div class="kb-entity-def">${ent.definition || '暂无定义'}</div>
|
||||
<div class="kb-entity-meta">
|
||||
📍 ${ent.mention_count || 0} 次提及 ·
|
||||
${ent.appears_in?.length || 0} 个文件
|
||||
</div>
|
||||
`;
|
||||
entityGrid.appendChild(card);
|
||||
});
|
||||
|
||||
// 渲染关系列表
|
||||
const relationsList = document.getElementById('kbRelationsList');
|
||||
relationsList.innerHTML = '';
|
||||
data.relations.forEach(rel => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'kb-glossary-item';
|
||||
item.innerHTML = `
|
||||
<div>
|
||||
<strong>${rel.source_name}</strong>
|
||||
<span style="color:#00d4ff;">→ ${rel.type} →</span>
|
||||
<strong>${rel.target_name}</strong>
|
||||
${rel.evidence ? `<div style="color:#666;font-size:0.8rem;margin-top:4px;">"${rel.evidence.substring(0, 100)}..."</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
relationsList.appendChild(item);
|
||||
});
|
||||
|
||||
// 渲染术语表
|
||||
const glossaryList = document.getElementById('kbGlossaryList');
|
||||
glossaryList.innerHTML = '';
|
||||
data.glossary.forEach(term => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'kb-glossary-item';
|
||||
item.innerHTML = `
|
||||
<div>
|
||||
<strong>${term.term}</strong>
|
||||
${term.pronunciation ? `<span style="color:#666;font-size:0.85rem;margin-left:8px;">(${term.pronunciation})</span>` : ''}
|
||||
<span style="color:#00d4ff;font-size:0.8rem;margin-left:8px;">出现 ${term.frequency} 次</span>
|
||||
</div>
|
||||
<button class="btn-icon" onclick="deleteGlossaryTerm('${term.id}')">删除</button>
|
||||
`;
|
||||
glossaryList.appendChild(item);
|
||||
});
|
||||
|
||||
// 渲染文件列表
|
||||
const transcriptsList = document.getElementById('kbTranscriptsList');
|
||||
transcriptsList.innerHTML = '';
|
||||
data.transcripts.forEach(t => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'kb-transcript-item';
|
||||
item.innerHTML = `
|
||||
<div>
|
||||
<span class="file-type-icon type-${t.type}">${t.type === 'audio' ? '🎵' : '📄'}</span>
|
||||
<span style="margin-left:8px;">${t.filename}</span>
|
||||
<div style="color:#666;font-size:0.8rem;margin-top:4px;">${new Date(t.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
`;
|
||||
transcriptsList.appendChild(item);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Load knowledge base failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Glossary Functions ====================
|
||||
|
||||
window.showAddTermModal = function() {
|
||||
document.getElementById('glossaryModal').classList.add('show');
|
||||
};
|
||||
|
||||
window.hideGlossaryModal = function() {
|
||||
document.getElementById('glossaryModal').classList.remove('show');
|
||||
};
|
||||
|
||||
window.saveGlossaryTerm = async function() {
|
||||
const term = document.getElementById('glossaryTerm').value.trim();
|
||||
const pronunciation = document.getElementById('glossaryPronunciation').value.trim();
|
||||
|
||||
if (!term) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/glossary`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ term, pronunciation })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
hideGlossaryModal();
|
||||
document.getElementById('glossaryTerm').value = '';
|
||||
document.getElementById('glossaryPronunciation').value = '';
|
||||
loadKnowledgeBase();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save glossary term failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
window.deleteGlossaryTerm = async function(termId) {
|
||||
if (!confirm('确定要删除这个术语吗?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/glossary/${termId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadKnowledgeBase();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete glossary term failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user