| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| let currentDatasetId = null; |
| let currentDataset = null; |
| let currentQAPair = null; |
| let annotationConfigs = []; |
| let annotationResults = {}; |
| let qaPairsList = []; |
| let totalQACount = 0; |
| let loadedQACount = 0; |
| let isLoadingMore = false; |
| const PAGE_SIZE = 50; |
| let isAdmin = false; |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| initEventListeners(); |
| checkAuth(); |
| checkUserRole(); |
| |
| const urlParams = new URLSearchParams(window.location.search); |
| const datasetId = urlParams.get('dataset_id'); |
| if (datasetId) { |
| initAnnotation(parseInt(datasetId)); |
| } else { |
| showError(t('error.missingDatasetIdParam')); |
| |
| setTimeout(() => { |
| window.location.href = '/user'; |
| }, 3000); |
| } |
| }); |
|
|
| |
|
|
| function checkAuth() { |
| if (!isLoggedIn()) { |
| window.location.href = '/auth'; |
| } |
| } |
|
|
| |
|
|
| async function checkUserRole() { |
| try { |
| |
| const users = await apiGet('/users/?skip=0&limit=1'); |
| if (users && users.length > 0) { |
| const currentUser = users[0]; |
| isAdmin = currentUser.is_superuser; |
| } |
| } catch (error) { |
| console.error(t('error.checkUserRoleFailed') + ':', error); |
| } |
| } |
|
|
|
|
| |
|
|
| function initEventListeners() { |
| |
| const logoutBtn = document.getElementById('logoutBtn'); |
| logoutBtn.addEventListener('click', () => { |
| clearToken(); |
| window.location.href = '/auth'; |
| }); |
|
|
| |
| const backBtn = document.getElementById('backBtn'); |
| if (backBtn) { |
| backBtn.addEventListener('click', () => { |
| |
| if (isAdmin) { |
| window.location.href = '/manager'; |
| } else { |
| window.location.href = '/user'; |
| } |
| }); |
| } |
| } |
|
|
| |
|
|
| async function initAnnotation(datasetId) { |
| try { |
| currentDatasetId = datasetId; |
| qaPairsList = []; |
| loadedQACount = 0; |
| totalQACount = 0; |
| await loadDatasetInfo(currentDatasetId); |
| await loadAnnotationConfigs(currentDatasetId); |
| await loadAnnotationResults(currentDatasetId); |
| await loadQAList(currentDatasetId); |
| setupScrollListener(); |
| } catch (error) { |
| console.error(t('error.initAnnotationPageFailed') + ':', error); |
| const errorMessage = error.data?.detail || error.data?.message || error.message || t('common.unknownError'); |
| showError(t('error.initAnnotationPageFailed') + ': ' + errorMessage); |
| |
| setTimeout(() => { |
| window.location.href = '/user'; |
| }, 3000); |
| } |
| } |
|
|
| |
|
|
| async function loadDatasetInfo(datasetId) { |
| try { |
| |
| currentDataset = await apiGet(`/datasets/annotation/${datasetId}/info`); |
| console.log(t('error.loadDatasetInfoFailed') + ':', currentDataset); |
| } catch (error) { |
| console.error(t('error.loadDatasetInfoFailed') + ':', error); |
| currentDataset = null; |
| } |
| } |
|
|
| |
|
|
| async function loadAnnotationConfigs(datasetId) { |
| try { |
| |
| annotationConfigs = await apiGet(`/datasets/annotation/${datasetId}/configs`); |
| console.log(t('error.loadAnnotationConfigsFailed') + ':', annotationConfigs); |
| } catch (error) { |
| console.error(t('error.loadAnnotationConfigsFailed') + ':', error); |
| annotationConfigs = []; |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function isItemAnnotated(itemId) { |
| if (!annotationConfigs || annotationConfigs.length === 0) { |
| return false; |
| } |
|
|
| |
| const validConfigIds = new Set(annotationConfigs.map(c => c.id)); |
|
|
| |
| const requiredConfigs = annotationConfigs.filter(config => config.required); |
| const requiredConfigIds = new Set(requiredConfigs.map(c => c.id)); |
|
|
| |
| const itemResults = annotationResults[itemId] || {}; |
| const annotatedConfigIds = new Set( |
| Object.keys(itemResults) |
| .map(Number) |
| .filter(id => validConfigIds.has(id)) |
| ); |
|
|
| |
| if (requiredConfigs.length > 0) { |
| |
| return requiredConfigIds.size > 0 && |
| Array.from(requiredConfigIds).every(id => annotatedConfigIds.has(id)); |
| } else { |
| |
| return annotatedConfigIds.size > 0; |
| } |
| } |
|
|
| |
|
|
| async function loadQAList(datasetId) { |
| try { |
| |
| const stats = await apiGet(`/datasets/annotation/${datasetId}/stats`).catch(() => null); |
| totalQACount = stats?.items_count || 0; |
|
|
| |
| await loadMoreQAPairs(datasetId, true); |
| updateProgressInfo(datasetId); |
| } catch (error) { |
| console.error(t('error.loadQAPairsListFailed') + ':', error); |
| const errorMessage = error.data?.detail || error.data?.message || error.message || t('common.unknownError'); |
| showError(t('error.loadQAPairsListFailed') + ': ' + errorMessage); |
| qaPairsList = []; |
| loadedQACount = 0; |
| } |
| } |
|
|
| |
|
|
| async function loadMoreQAPairs(datasetId, isInitial = false) { |
| |
| if (isLoadingMore) { |
| return; |
| } |
| if (!isInitial && loadedQACount >= totalQACount && totalQACount > 0) { |
| return; |
| } |
|
|
| try { |
| isLoadingMore = true; |
| showLoadingIndicator(true); |
|
|
| |
| const skip = loadedQACount; |
| const limit = PAGE_SIZE; |
| const qaPairs = await apiGet(`/datasets/annotation/${datasetId}/items?skip=${skip}&limit=${limit}`); |
|
|
| if (qaPairs.length === 0) { |
| showLoadingIndicator(false); |
| isLoadingMore = false; |
| return; |
| } |
|
|
| |
| qaPairsList = qaPairsList.concat(qaPairs); |
| loadedQACount += qaPairs.length; |
|
|
| |
| renderQAList(qaPairs, !isInitial); |
|
|
| |
| if (qaPairs.length < limit) { |
| totalQACount = loadedQACount; |
| } |
|
|
| showLoadingIndicator(false); |
| isLoadingMore = false; |
| } catch (error) { |
| console.error(t('error.loadMoreQAPairsFailed') + ':', error); |
| showLoadingIndicator(false); |
| isLoadingMore = false; |
| const errorMessage = error.data?.detail || error.data?.message || error.message || t('common.unknownError'); |
| showError(t('error.loadMoreQAPairsFailed') + ': ' + errorMessage); |
| } |
| } |
|
|
| |
|
|
| function renderQAList(qaPairs, append = false) { |
| const listContainer = document.getElementById('qaListBody'); |
|
|
| if (!qaPairs || qaPairs.length === 0) { |
| if (!append) { |
| listContainer.innerHTML = '<li class="qa-list-empty">暂无QA对</li>'; |
| } |
| return; |
| } |
|
|
| |
| if (append) { |
| const loadingItem = listContainer.querySelector('.qa-list-loading'); |
| if (loadingItem) { |
| loadingItem.remove(); |
| } |
| } else { |
| |
| listContainer.innerHTML = ''; |
| } |
|
|
| |
| const itemsHtml = qaPairs.map((qa, index) => { |
| |
| const rowNumber = append ? loadedQACount - qaPairs.length + index + 1 : index + 1; |
|
|
| |
| const isAnnotated = isItemAnnotated(qa.id); |
| const status = isAnnotated ? |
| '<span class="status-badge active">已标注</span>' : |
| '<span class="status-badge inactive">未标注</span>'; |
|
|
| |
| const questionText = qa.question.length > 120 ? |
| qa.question.substring(0, 120) + '...' : qa.question; |
|
|
| |
| const isActive = currentQAPair && currentQAPair.id === qa.id ? 'active' : ''; |
|
|
| return ` |
| <li class="qa-list-item ${isActive}" onclick="loadQAPair('${qa.id}')" title="${escapeHtml(qa.question)}"> |
| <div class="qa-list-item-content"> |
| <span class="qa-list-item-number">${rowNumber}.</span> |
| <span class="qa-list-item-question">${escapeHtml(questionText)}</span> |
| </div> |
| <div class="qa-list-item-status">${status}</div> |
| </li> |
| `; |
| }).join(''); |
|
|
| |
| if (append) { |
| listContainer.insertAdjacentHTML('beforeend', itemsHtml); |
| } else { |
| listContainer.innerHTML = itemsHtml; |
| } |
|
|
| |
| if (loadedQACount < totalQACount || totalQACount === 0) { |
| const loadingHtml = '<li class="qa-list-loading" id="qaListLoading">加载中...</li>'; |
| listContainer.insertAdjacentHTML('beforeend', loadingHtml); |
| } |
| } |
|
|
| |
|
|
| async function loadQAPair(itemId) { |
| try { |
| |
| const qaPair = await apiGet(`/datasets/annotation/${currentDatasetId}/items/${itemId}`); |
| currentQAPair = qaPair; |
| renderAnnotationForm(qaPair); |
| |
| updateQAListActiveState(itemId); |
| } catch (error) { |
| console.error(t('error.loadQAPairFailed') + ':', error); |
| const errorMessage = error.data?.detail || error.data?.message || error.message || t('common.unknownError'); |
| showError(t('error.loadQAPairFailed') + ': ' + errorMessage); |
| } |
| } |
|
|
| |
|
|
| function updateQAListActiveState(activeItemId) { |
| const listItems = document.querySelectorAll('.qa-list-item'); |
| listItems.forEach(item => { |
| const onclickAttr = item.getAttribute('onclick'); |
| if (onclickAttr && onclickAttr.includes(`'${activeItemId}'`)) { |
| item.classList.add('active'); |
| } else { |
| item.classList.remove('active'); |
| } |
| }); |
| } |
|
|
| |
|
|
| async function updateQAListAfterSave() { |
| |
| if (currentDatasetId) { |
| try { |
| |
| await loadAnnotationResults(currentDatasetId); |
| |
| updateQAListItemStatus(currentQAPair?.id); |
| |
| if (currentQAPair) { |
| updateQAListActiveState(currentQAPair.id); |
| } |
| } catch (error) { |
| console.error(t('error.updateListFailed') + ':', error); |
| } |
| } |
| } |
|
|
| |
|
|
| function updateQAListItemStatus(itemId) { |
| if (!itemId) return; |
|
|
| const listItems = document.querySelectorAll('.qa-list-item'); |
| listItems.forEach(item => { |
| const onclickAttr = item.getAttribute('onclick'); |
| if (onclickAttr && onclickAttr.includes(`'${itemId}'`)) { |
| |
| const qa = qaPairsList.find(q => q.id === itemId); |
| if (qa) { |
| |
| const isAnnotated = isItemAnnotated(qa.id); |
| const status = isAnnotated ? |
| '<span class="status-badge active">已标注</span>' : |
| '<span class="status-badge inactive">未标注</span>'; |
|
|
| |
| const statusDiv = item.querySelector('.qa-list-item-status'); |
| if (statusDiv) { |
| statusDiv.innerHTML = status; |
| } |
| } |
| } |
| }); |
| } |
|
|
| |
|
|
| async function loadAnnotationResults(datasetId) { |
| try { |
| |
| const results = await apiGet(`/annotation-results/datasets/${datasetId}/results?skip=0&limit=10000`).catch(() => { |
| |
| return []; |
| }); |
|
|
| |
| annotationResults = {}; |
| if (Array.isArray(results)) { |
| results.forEach(result => { |
| if (!annotationResults[result.dataset_item_id]) { |
| annotationResults[result.dataset_item_id] = {}; |
| } |
| annotationResults[result.dataset_item_id][result.annotation_config_id] = result; |
| }); |
| } |
|
|
| |
| updateProgressInfo(datasetId); |
| } catch (error) { |
| console.error(t('error.loadAnnotationResultsFailed') + ':', error); |
| annotationResults = {}; |
| } |
| } |
|
|
| |
|
|
| async function updateProgressInfo(datasetId) { |
| try { |
| |
| let total = totalQACount; |
| if (total === 0) { |
| try { |
| const stats = await apiGet(`/datasets/annotation/${datasetId}/stats`); |
| total = stats.items_count || 0; |
| totalQACount = total; |
| } catch (error) { |
| console.warn(t('error.loadStatsFailed') + ':', error); |
| |
| total = loadedQACount; |
| } |
| } |
|
|
| let annotated = 0; |
|
|
| |
| |
| if (qaPairsList.length > 0) { |
| qaPairsList.forEach(qa => { |
| if (isItemAnnotated(qa.id)) { |
| annotated++; |
| } |
| }); |
| } |
|
|
| const progressInfo = document.getElementById('progressInfo'); |
| const annotatedCount = document.getElementById('annotatedCount'); |
| const totalCount = document.getElementById('totalCount'); |
| const progressBar = document.getElementById('progressBar'); |
|
|
| if (total > 0) { |
| progressInfo.style.display = 'block'; |
| const percentage = Math.round((annotated / total) * 100); |
| annotatedCount.textContent = annotated; |
| totalCount.textContent = total; |
| |
| progressBar.style.width = percentage + '%'; |
| |
| let percentageSpan = progressInfo.querySelector('.progress-percentage'); |
| if (!percentageSpan) { |
| percentageSpan = document.createElement('span'); |
| percentageSpan.className = 'progress-percentage'; |
| percentageSpan.style.marginLeft = '8px'; |
| percentageSpan.style.fontWeight = '600'; |
| annotatedCount.parentNode.appendChild(percentageSpan); |
| } |
| percentageSpan.textContent = `(${percentage}%)`; |
| } else { |
| progressInfo.style.display = 'none'; |
| } |
| } catch (error) { |
| console.error('更新进度信息失败:', error); |
| } |
| } |
|
|
| |
|
|
| function renderAnnotationForm(qaPair) { |
| const qaDisplay = document.getElementById('qaDisplay'); |
| const annotationFormPanel = document.getElementById('annotationFormPanel'); |
|
|
| if (!qaPair) { |
| qaDisplay.innerHTML = '<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">未找到QA对</div></div>'; |
| annotationFormPanel.style.display = 'none'; |
| return; |
| } |
|
|
| if (annotationConfigs.length === 0) { |
| qaDisplay.innerHTML = '<div class="empty-state"><div class="empty-state-icon">⚙️</div><div class="empty-state-text">该数据集暂无标注配置,请联系管理员</div></div>'; |
| annotationFormPanel.style.display = 'none'; |
| return; |
| } |
|
|
| |
| const currentResults = annotationResults[qaPair.id] || {}; |
|
|
| const annotationForms = annotationConfigs.map(config => { |
| const existingResult = currentResults[config.id]; |
| return renderAnnotationField(config, existingResult); |
| }).join(''); |
|
|
| |
| const extraFieldsHtml = renderExtraFields(qaPair); |
|
|
| |
| qaDisplay.innerHTML = ` |
| <div class="qa-card"> |
| <div class="qa-card-header"> |
| <h3>QA对 #${qaPair.id}</h3> |
| <div class="qa-card-navigation"> |
| <button type="button" class="btn btn-info btn-compact" onclick="loadPreviousQAPair()">上一条</button> |
| <button type="button" class="btn btn-info btn-compact" onclick="loadNextQAPair()">研究领域和该问题不匹配,建议转给其它专家</button> |
| </div> |
| </div> |
| <div class="qa-card-body"> |
| <div class="qa-item"> |
| <div class="qa-label">问题</div> |
| <div class="qa-text">${escapeHtml(qaPair.question)}</div> |
| </div> |
| <div class="qa-item"> |
| <div class="qa-label">答案</div> |
| <div class="qa-text">${escapeHtml(qaPair.answer)}</div> |
| </div> |
| ${extraFieldsHtml} |
| <div class="qa-card-navigation-bottom"> |
| <button type="button" class="btn btn-info btn-compact" onclick="loadPreviousQAPair()">上一条</button> |
| <button type="button" class="btn btn-info btn-compact" onclick="loadNextQAPair()">下一条</button> |
| </div> |
| </div> |
| </div> |
| `; |
|
|
| |
| const annotationFormBody = document.getElementById('annotationFormBody'); |
| annotationFormBody.innerHTML = ` |
| <form class="annotation-form" id="annotationForm" onsubmit="submitAnnotation(event)"> |
| ${annotationForms} |
| <div class="annotation-actions"> |
| <button type="button" class="btn btn-secondary" onclick="clearAnnotationForm()">清空</button> |
| <button type="submit" class="btn btn-primary">保存标注</button> |
| </div> |
| </form> |
| <div class="annotation-navigation"> |
| <button type="button" class="btn btn-info btn-compact" onclick="loadPreviousQAPair()">上一条</button> |
| <button type="button" class="btn btn-info btn-compact" onclick="loadNextQAPair()">下一条</button> |
| </div> |
| `; |
| annotationFormPanel.style.display = 'flex'; |
| } |
|
|
| |
|
|
| function renderExtraFields(qaPair) { |
| if (!currentDataset || !currentDataset.display_extra_fields || !Array.isArray(currentDataset.display_extra_fields)) { |
| return ''; |
| } |
|
|
| const extraFields = currentDataset.display_extra_fields; |
| if (extraFields.length === 0) { |
| return ''; |
| } |
|
|
| |
| const standardFields = ['id', 'dataset_id', 'question', 'answer']; |
| const extraFieldsHtml = extraFields.map(fieldName => { |
| |
| if (qaPair.hasOwnProperty(fieldName)) { |
| const fieldValue = qaPair[fieldName]; |
| |
| let displayValue = ''; |
| if (fieldValue === null || fieldValue === undefined) { |
| displayValue = ''; |
| } else if (typeof fieldValue === 'object') { |
| displayValue = JSON.stringify(fieldValue, null, 2); |
| } else { |
| displayValue = String(fieldValue); |
| } |
|
|
| return ` |
| <div class="qa-item"> |
| <div class="qa-label">${escapeHtml(fieldName)}</div> |
| <div class="qa-text">${escapeHtml(displayValue)}</div> |
| </div> |
| `; |
| } |
| return ''; |
| }).filter(html => html !== '').join(''); |
|
|
| return extraFieldsHtml; |
| } |
|
|
| |
|
|
| function renderAnnotationField(config, existingResult = null) { |
| const fieldId = `annotation_${config.id}`; |
| const required = config.required ? '<span class="required">*</span>' : ''; |
| const description = config.description ? `<div class="annotation-field-description">${escapeHtml(config.description)}</div>` : ''; |
|
|
| let inputHtml = ''; |
| const existingValue = existingResult ? existingResult.value : null; |
|
|
| switch (config.annotation_type) { |
| case 'score': |
| inputHtml = renderScoreField(config, fieldId, existingValue); |
| break; |
| case 'category': |
| inputHtml = renderCategoryField(config, fieldId, existingValue); |
| break; |
| case 'text': |
| inputHtml = renderTextField(config, fieldId, existingValue); |
| break; |
| case 'single_choice': |
| inputHtml = renderSingleChoiceField(config, fieldId, existingValue); |
| break; |
| case 'multi_choice': |
| inputHtml = renderMultiChoiceField(config, fieldId, existingValue); |
| break; |
| case 'binary': |
| inputHtml = renderBinaryField(config, fieldId, existingValue); |
| break; |
| default: |
| inputHtml = `<div class="annotation-input">未知的标注类型: ${config.annotation_type}</div>`; |
| } |
|
|
| |
| let reasonHtml = ''; |
| if (config.show_reason) { |
| const existingReason = existingResult?.notes || ''; |
| reasonHtml = ` |
| <div style="margin-top: 8px;"> |
| <label style="display: block; font-size: 0.85em; color: #666; margin-bottom: 4px; font-weight: normal;">标注理由(可选)</label> |
| <textarea id="${fieldId}_reason" |
| class="annotation-input" |
| placeholder="请说明标注的理由或依据">${escapeHtml(existingReason)}</textarea> |
| </div> |
| `; |
| } |
|
|
| |
| let confidenceHtml = ''; |
| if (config.show_confidence) { |
| const existingConfidence = existingResult?.confidence !== undefined && existingResult?.confidence !== null ? existingResult.confidence : ''; |
| confidenceHtml = ` |
| <div style="margin-top: 8px;"> |
| <label style="display: block; font-size: 0.85em; color: #666; margin-bottom: 4px; font-weight: normal;">置信度(0-1)</label> |
| <input type="number" |
| id="${fieldId}_confidence" |
| class="annotation-input" |
| min="0" |
| max="1" |
| step="0.01" |
| value="${existingConfidence}" |
| placeholder="0.00 - 1.00"> |
| </div> |
| `; |
| } |
|
|
| return ` |
| <div class="annotation-field"> |
| <label class="annotation-field-label"> |
| ${escapeHtml(config.name)}${required} |
| </label> |
| ${description} |
| ${inputHtml} |
| ${reasonHtml} |
| ${confidenceHtml} |
| </div> |
| `; |
| } |
|
|
| |
|
|
| function renderScoreField(config, fieldId, existingValue) { |
| const scoreConfig = config.config; |
| const min = scoreConfig.min_score || 1; |
| const max = scoreConfig.max_score || 5; |
| const step = scoreConfig.score_step || 1; |
| const currentValue = existingValue?.score?.score || ''; |
|
|
| return ` |
| <div class="score-input-group"> |
| <input type="number" |
| id="${fieldId}" |
| class="annotation-input score-input" |
| min="${min}" |
| max="${max}" |
| step="${step}" |
| value="${currentValue}" |
| required="${config.required}"> |
| <span class="score-range">范围: ${min} - ${max}</span> |
| </div> |
| `; |
| } |
|
|
| function renderCategoryField(config, fieldId, existingValue) { |
| const categories = config.config.categories || []; |
| const currentValue = existingValue?.category?.category || ''; |
|
|
| if (categories.length === 0) { |
| return `<input type="text" id="${fieldId}" class="annotation-input" value="${escapeHtml(currentValue)}" ${config.required ? 'required' : ''}>`; |
| } |
|
|
| const options = categories.map(cat => { |
| const selected = cat === currentValue ? 'selected' : ''; |
| return `<option value="${escapeHtml(cat)}" ${selected}>${escapeHtml(cat)}</option>`; |
| }).join(''); |
|
|
| return `<select id="${fieldId}" class="annotation-input" ${config.required ? 'required' : ''}>${options}</select>`; |
| } |
|
|
| function renderTextField(config, fieldId, existingValue) { |
| const maxLength = config.config.max_length || ''; |
| const currentValue = existingValue?.text?.text || ''; |
| const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : ''; |
|
|
| return `<textarea id="${fieldId}" class="annotation-input" ${maxLengthAttr} ${config.required ? 'required' : ''}>${escapeHtml(currentValue)}</textarea>`; |
| } |
|
|
| function renderSingleChoiceField(config, fieldId, existingValue) { |
| const options = config.config.options || []; |
| const currentValue = existingValue?.choice?.selected_options?.[0] || ''; |
|
|
| return ` |
| <div class="annotation-options"> |
| ${options.map(opt => { |
| const checked = opt.option_id === currentValue ? 'checked' : ''; |
| return ` |
| <label class="annotation-option"> |
| <input type="radio" |
| name="${fieldId}" |
| value="${escapeHtml(opt.option_id)}" |
| ${checked} |
| ${config.required ? 'required' : ''}> |
| <div class="annotation-option-label"> |
| <div>${escapeHtml(opt.label)}</div> |
| ${opt.description ? `<div class="annotation-option-description">${escapeHtml(opt.description)}</div>` : ''} |
| </div> |
| </label> |
| `; |
| }).join('')} |
| </div> |
| `; |
| } |
|
|
| function renderMultiChoiceField(config, fieldId, existingValue) { |
| const options = config.config.options || []; |
| const currentValues = existingValue?.choice?.selected_options || []; |
|
|
| return ` |
| <div class="annotation-options"> |
| ${options.map(opt => { |
| const checked = currentValues.includes(opt.option_id) ? 'checked' : ''; |
| return ` |
| <label class="annotation-option"> |
| <input type="checkbox" |
| name="${fieldId}[]" |
| value="${escapeHtml(opt.option_id)}" |
| ${checked}> |
| <div class="annotation-option-label"> |
| <div>${escapeHtml(opt.label)}</div> |
| ${opt.description ? `<div class="annotation-option-description">${escapeHtml(opt.description)}</div>` : ''} |
| </div> |
| </label> |
| `; |
| }).join('')} |
| </div> |
| `; |
| } |
|
|
| function renderBinaryField(config, fieldId, existingValue) { |
| const binaryConfig = config.config; |
| const trueLabel = binaryConfig.true_label || '是'; |
| const falseLabel = binaryConfig.false_label || '否'; |
| const currentValue = existingValue?.binary?.value; |
|
|
| let checkedTrue = ''; |
| let checkedFalse = ''; |
| if (currentValue === true) { |
| checkedTrue = 'checked'; |
| } else if (currentValue === false) { |
| checkedFalse = 'checked'; |
| } |
|
|
| return ` |
| <div class="annotation-options"> |
| <label class="annotation-option"> |
| <input type="radio" |
| name="${fieldId}" |
| value="true" |
| ${checkedTrue} |
| ${config.required ? 'required' : ''}> |
| <div class="annotation-option-label">${escapeHtml(trueLabel)}</div> |
| </label> |
| <label class="annotation-option"> |
| <input type="radio" |
| name="${fieldId}" |
| value="false" |
| ${checkedFalse} |
| ${config.required ? 'required' : ''}> |
| <div class="annotation-option-label">${escapeHtml(falseLabel)}</div> |
| </label> |
| </div> |
| `; |
| } |
|
|
| |
|
|
| async function submitAnnotation(event) { |
| event.preventDefault(); |
|
|
| if (!currentQAPair || !currentDatasetId) { |
| showError(t('error.selectQAPairFirst')); |
| return; |
| } |
|
|
| try { |
| const results = []; |
|
|
| |
| for (const config of annotationConfigs) { |
| const fieldId = `annotation_${config.id}`; |
| const value = collectAnnotationValue(config, fieldId); |
|
|
| if (config.required && !value) { |
| showError(t('error.fillRequiredField', { name: config.name })); |
| return; |
| } |
|
|
| if (value) { |
| |
| const result = { |
| dataset_id: currentDatasetId, |
| dataset_item_id: currentQAPair.id, |
| annotation_config_id: config.id, |
| value: value |
| }; |
|
|
| |
| if (config.show_reason) { |
| const reasonInput = document.getElementById(`${fieldId}_reason`); |
| const reason = reasonInput?.value?.trim() || null; |
| result.notes = reason; |
| } |
|
|
| |
| if (config.show_confidence) { |
| const confidenceInput = document.getElementById(`${fieldId}_confidence`); |
| const confidenceValue = confidenceInput?.value?.trim(); |
| if (confidenceValue) { |
| const confidence = parseFloat(confidenceValue); |
| |
| if (!isNaN(confidence) && confidence >= 0 && confidence <= 1) { |
| result.confidence = confidence; |
| } else { |
| showError(t('error.confidenceRangeError', { name: config.name })); |
| return; |
| } |
| } else { |
| result.confidence = null; |
| } |
| } |
|
|
| |
| const existingResult = annotationResults[currentQAPair.id]?.[config.id]; |
| if (existingResult && existingResult.id) { |
| |
| await apiPut(`/annotation-results/${existingResult.id}`, result); |
| } else { |
| |
| await apiPost('/annotation-results/', result); |
| } |
|
|
| results.push(result); |
| } |
| } |
|
|
| |
| if (!annotationResults[currentQAPair.id]) { |
| annotationResults[currentQAPair.id] = {}; |
| } |
| results.forEach(result => { |
| annotationResults[currentQAPair.id][result.annotation_config_id] = result; |
| }); |
|
|
| |
| await updateQAListAfterSave(); |
| await updateProgressInfo(currentDatasetId); |
|
|
| showSuccess(t('error.annotationSaveSuccess')); |
|
|
| |
| await loadNextUnannotatedQAPair(); |
|
|
| } catch (error) { |
| console.error(t('error.saveAnnotationFailed') + ':', error); |
| showError(t('error.saveAnnotationFailed') + ': ' + (error.message || t('common.unknownError'))); |
| } |
| } |
|
|
| |
|
|
| function collectAnnotationValue(config, fieldId) { |
| switch (config.annotation_type) { |
| case 'score': |
| const scoreInput = document.getElementById(fieldId); |
| if (!scoreInput || !scoreInput.value) return null; |
| return { |
| score: { |
| score: parseFloat(scoreInput.value) |
| } |
| }; |
|
|
| case 'category': |
| const categoryInput = document.getElementById(fieldId); |
| if (!categoryInput || !categoryInput.value) return null; |
| return { |
| category: { |
| category: categoryInput.value |
| } |
| }; |
|
|
| case 'text': |
| const textInput = document.getElementById(fieldId); |
| if (!textInput || !textInput.value.trim()) return null; |
| return { |
| text: { |
| text: textInput.value.trim() |
| } |
| }; |
|
|
| case 'single_choice': |
| const radioInput = document.querySelector(`input[name="${fieldId}"]:checked`); |
| if (!radioInput) return null; |
| return { |
| choice: { |
| selected_options: [radioInput.value] |
| } |
| }; |
|
|
| case 'multi_choice': |
| const checkboxes = document.querySelectorAll(`input[name="${fieldId}[]"]:checked`); |
| if (checkboxes.length === 0) return null; |
| return { |
| choice: { |
| selected_options: Array.from(checkboxes).map(cb => cb.value) |
| } |
| }; |
|
|
| case 'binary': |
| const binaryInput = document.querySelector(`input[name="${fieldId}"]:checked`); |
| if (!binaryInput) return null; |
| return { |
| binary: { |
| value: binaryInput.value === 'true' |
| } |
| }; |
|
|
| default: |
| return null; |
| } |
| } |
|
|
| |
|
|
| async function loadNextUnannotatedQAPair() { |
| try { |
| |
| const currentIndex = qaPairsList.findIndex(qa => qa.id === currentQAPair.id); |
| for (let i = currentIndex + 1; i < qaPairsList.length; i++) { |
| const qa = qaPairsList[i]; |
| |
| const isAnnotated = isItemAnnotated(qa.id); |
| if (!isAnnotated) { |
| await loadQAPair(qa.id); |
| return; |
| } |
| } |
|
|
| |
| if (loadedQACount < totalQACount || totalQACount === 0) { |
| await loadMoreQAPairs(currentDatasetId); |
| |
| const newCurrentIndex = qaPairsList.findIndex(qa => qa.id === currentQAPair.id); |
| for (let i = newCurrentIndex + 1; i < qaPairsList.length; i++) { |
| const qa = qaPairsList[i]; |
| |
| const isAnnotated = isItemAnnotated(qa.id); |
| if (!isAnnotated) { |
| await loadQAPair(qa.id); |
| return; |
| } |
| } |
| } |
| } catch (error) { |
| console.error('加载下一个QA对失败:', error); |
| } |
| } |
|
|
| |
|
|
| async function loadPreviousQAPair() { |
| if (!currentQAPair || !currentDatasetId) { |
| showError('请先选择QA对'); |
| return; |
| } |
|
|
| try { |
| |
| const currentIndex = qaPairsList.findIndex(qa => qa.id === currentQAPair.id); |
|
|
| if (currentIndex === -1) { |
| showError('未找到当前QA对'); |
| return; |
| } |
|
|
| |
| if (currentIndex > 0) { |
| const previousQAPair = qaPairsList[currentIndex - 1]; |
| await loadQAPair(previousQAPair.id); |
| } else { |
| showSuccess('已经是第一条数据了'); |
| } |
| } catch (error) { |
| console.error('加载上一条QA对失败:', error); |
| showError('加载上一条QA对失败: ' + (error.message || '未知错误')); |
| } |
| } |
|
|
| |
|
|
| async function loadNextQAPair() { |
| if (!currentQAPair || !currentDatasetId) { |
| showError('请先选择QA对'); |
| return; |
| } |
|
|
| try { |
| |
| const currentIndex = qaPairsList.findIndex(qa => qa.id === currentQAPair.id); |
|
|
| if (currentIndex === -1) { |
| showError('未找到当前QA对'); |
| return; |
| } |
|
|
| |
| if (currentIndex < qaPairsList.length - 1) { |
| const nextQAPair = qaPairsList[currentIndex + 1]; |
| await loadQAPair(nextQAPair.id); |
| } else { |
| |
| if (loadedQACount < totalQACount || totalQACount === 0) { |
| await loadMoreQAPairs(currentDatasetId); |
| |
| const newCurrentIndex = qaPairsList.findIndex(qa => qa.id === currentQAPair.id); |
| if (newCurrentIndex < qaPairsList.length - 1) { |
| const nextQAPair = qaPairsList[newCurrentIndex + 1]; |
| await loadQAPair(nextQAPair.id); |
| } else { |
| showSuccess('已经是最后一条数据了'); |
| } |
| } else { |
| showSuccess('已经是最后一条数据了'); |
| } |
| } |
| } catch (error) { |
| console.error('加载下一条QA对失败:', error); |
| showError('加载下一条QA对失败: ' + (error.message || '未知错误')); |
| } |
| } |
|
|
| |
|
|
| function clearAnnotationForm() { |
| if (confirm('确定要清空当前标注吗?')) { |
| const form = document.getElementById('annotationForm'); |
| if (form) { |
| form.reset(); |
| } |
| } |
| } |
|
|
| |
|
|
| function clearAnnotationContent() { |
| const qaDisplay = document.getElementById('qaDisplay'); |
| const annotationFormPanel = document.getElementById('annotationFormPanel'); |
| qaDisplay.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📝</div><div class="empty-state-text">加载中...</div></div>'; |
| annotationFormPanel.style.display = 'none'; |
|
|
| const listContainer = document.getElementById('qaListBody'); |
| listContainer.innerHTML = '<li class="qa-list-empty">加载中...</li>'; |
|
|
| const progressInfo = document.getElementById('progressInfo'); |
| progressInfo.style.display = 'none'; |
| } |
|
|
| |
|
|
| function escapeHtml(text) { |
| if (text == null) return ''; |
| const div = document.createElement('div'); |
| div.textContent = text; |
| return div.innerHTML; |
| } |
|
|
| function showMessage(message, type = 'info') { |
| |
| const messageEl = document.createElement('div'); |
| messageEl.className = `message ${type} show`; |
| messageEl.textContent = message; |
| messageEl.style.cssText = ` |
| position: fixed; |
| top: 20px; |
| right: 20px; |
| z-index: 2000; |
| padding: 16px 20px; |
| border-radius: 8px; |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
| animation: slideDown 0.3s ease-out; |
| max-width: 400px; |
| `; |
|
|
| |
| if (type === 'success') { |
| messageEl.style.background = '#d4edda'; |
| messageEl.style.color = '#155724'; |
| messageEl.style.border = '1px solid #c3e6cb'; |
| } else if (type === 'error') { |
| messageEl.style.background = '#f8d7da'; |
| messageEl.style.color = '#721c24'; |
| messageEl.style.border = '1px solid #f5c6cb'; |
| } else { |
| messageEl.style.background = '#d1ecf1'; |
| messageEl.style.color = '#0c5460'; |
| messageEl.style.border = '1px solid #bee5eb'; |
| } |
|
|
| document.body.appendChild(messageEl); |
|
|
| |
| setTimeout(() => { |
| messageEl.style.animation = 'fadeOut 0.3s ease-out'; |
| setTimeout(() => { |
| if (messageEl.parentNode) { |
| messageEl.parentNode.removeChild(messageEl); |
| } |
| }, 300); |
| }, 3000); |
| } |
|
|
| function showError(message) { |
| |
| let errorMsg = message; |
| if (typeof message === 'object') { |
| errorMsg = message.detail || message.message || JSON.stringify(message); |
| } |
| showMessage('错误: ' + errorMsg, 'error'); |
| } |
|
|
| function showSuccess(message) { |
| showMessage('成功: ' + message, 'success'); |
| } |
|
|
| |
|
|
| let scrollListener = null; |
|
|
| function setupScrollListener() { |
| |
| removeScrollListener(); |
|
|
| const listContainer = document.querySelector('.qa-list-container'); |
| if (!listContainer) return; |
|
|
| scrollListener = () => { |
| |
| const scrollTop = listContainer.scrollTop; |
| const scrollHeight = listContainer.scrollHeight; |
| const clientHeight = listContainer.clientHeight; |
|
|
| |
| if (scrollTop + clientHeight >= scrollHeight - 50) { |
| if (currentDatasetId && !isLoadingMore) { |
| |
| if (loadedQACount < totalQACount || totalQACount === 0) { |
| loadMoreQAPairs(currentDatasetId); |
| } |
| } |
| } |
| }; |
|
|
| listContainer.addEventListener('scroll', scrollListener); |
| } |
|
|
| function removeScrollListener() { |
| if (scrollListener) { |
| const listContainer = document.querySelector('.qa-list-container'); |
| if (listContainer) { |
| listContainer.removeEventListener('scroll', scrollListener); |
| } |
| scrollListener = null; |
| } |
| } |
|
|
| |
|
|
| function showLoadingIndicator(show) { |
| const listContainer = document.getElementById('qaListBody'); |
| if (!listContainer) return; |
|
|
| let loadingItem = listContainer.querySelector('.qa-list-loading'); |
|
|
| if (show) { |
| if (!loadingItem) { |
| loadingItem = document.createElement('li'); |
| loadingItem.className = 'qa-list-loading'; |
| loadingItem.id = 'qaListLoading'; |
| loadingItem.textContent = '加载中...'; |
| listContainer.appendChild(loadingItem); |
| } |
| } else { |
| if (loadingItem) { |
| |
| if (loadedQACount >= totalQACount && totalQACount > 0) { |
| loadingItem.remove(); |
| } |
| } |
| } |
| } |
|
|