(() => { // The graph page owns the reminder when the chat is embedded inside it. if (window.self !== window.top) return; const header = document.querySelector('header'); if (!header) return; const make = (tag, className, text) => { const element = document.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; }; const trigger = make('button', 'dq-trigger'); trigger.type = 'button'; trigger.hidden = true; trigger.id = 'dataQualityAlert'; trigger.setAttribute('aria-haspopup', 'dialog'); trigger.append(make('span', 'dq-icon', '!')); const badge = make('span', '', '数据问题'); trigger.append(badge); header.append(trigger); const dialog = make('dialog', 'dq-dialog'); dialog.id = 'dataQualityDialog'; dialog.setAttribute('aria-labelledby', 'dqTitle'); const heading = make('div', 'dq-head'); const title = make('h2', '', '数据质量提醒'); title.id = 'dqTitle'; const close = make('button', 'dq-close', '×'); close.type = 'button'; close.setAttribute('aria-label', '关闭数据质量提醒'); heading.append(title, close); const body = make('div', 'dq-body'); body.setAttribute('aria-live', 'polite'); dialog.append(heading, body); document.body.append(dialog); close.onclick = () => dialog.close(); dialog.addEventListener('click', event => { if (event.target === dialog) { const box = dialog.getBoundingClientRect(); if (event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom) dialog.close(); }}); let summary = null, selectedFile = '', generation = 0, loading = false; const endpoint = () => `${typeof apiBase === 'function' ? apiBase() : location.origin}/api/data-quality`; async function request(parameters = '') { const response = await fetch(endpoint() + parameters, {cache:'no-store', signal:AbortSignal.timeout(15000)}); const data = await response.json(); if (!response.ok) { const error = new Error(data.detail || '读取失败,请重试'); error.status = response.status; throw error; } return data; } function updateBadge(data) { summary = data; trigger.hidden = data.total === 0; trigger.classList.remove('dq-unavailable'); badge.textContent = `数据问题 ${data.total}`; trigger.setAttribute('aria-label', `${Object.keys(data.datasets).length} 份文件共 ${data.total} 条数据问题,点击查看`); trigger.title = trigger.getAttribute('aria-label'); } function showError(error) { body.replaceChildren(make('p', 'dq-error', error.message || '读取失败,请稍后重试')); const retry = make('button', 'dq-file', '重新加载'); retry.onclick = loadDetails; body.append(retry); } function render(data) { body.replaceChildren(); body.append(make('p', 'dq-note', '以下记录因缺少主键未进入知识图谱。请在源数据中补齐后重新更新;此处展示该次发布保留的原始记录。')); if (!data.total) { body.append(make('p', 'dq-status', '当前版本没有缺少主键的记录。')); return; } const names = Object.keys(data.datasets); if (!names.includes(selectedFile)) selectedFile = names[0]; body.append(make('p', 'dq-status', `共 ${names.length} 份文件,${data.total} 条问题记录`)); const files = make('div', 'dq-files'); files.setAttribute('aria-label', '按来源文件查看'); const list = make('div'); const showFile = name => { selectedFile = name; for (const button of files.children) button.setAttribute('aria-pressed', String(button.dataset.file === name)); list.replaceChildren(); for (const record of data.records.filter(record => record.template === name)) { const card = make('section', 'dq-card'); card.append(make('h3', '', `${record.file} · ${record.sheet} · 第 ${record.row} 行`)); card.append(make('p', 'dq-problem', `${record.message};${record.action}`)); if (record.data_error) card.append(make('p', 'dq-problem', record.data_error)); if (record.data) { const fields = make('dl', 'dq-data'); for (const [key, value] of Object.entries(record.data)) { const missing = record.fields.includes(key); fields.append(make('dt', missing ? 'dq-missing' : '', key)); const empty = value === null || value === undefined || (typeof value === 'string' && !value.trim()); fields.append(make('dd', missing ? 'dq-missing' : '', empty ? '(空)' : typeof value === 'object' ? JSON.stringify(value) : String(value))); } card.append(fields); } list.append(card); } }; for (const name of names) { const button = make('button', 'dq-file', `${name}.xlsx (${data.datasets[name]})`); button.type = 'button'; button.dataset.file = name; button.onclick = () => showFile(name); files.append(button); } body.append(files, list); showFile(selectedFile); } async function loadDetails() { const id = ++generation; loading = true; body.replaceChildren(make('p', 'dq-status', '正在读取问题记录…')); try { let data; try { data = await request('?details=true' + (summary ? '&data_version=' + encodeURIComponent(summary.data_version) : '')); } catch (error) { if (error.status !== 409) throw error; data = await request('?details=true'); } if (id !== generation) return; updateBadge(data); render(data); } catch (error) { if (id === generation) showError(error); } finally { if (id === generation) loading = false; } } trigger.onclick = () => { dialog.showModal(); loadDetails(); }; async function refresh() { if (loading) return; try { const previous = summary?.data_version; const data = await request(); updateBadge(data); if (dialog.open && previous !== data.data_version) loadDetails(); } catch (error) { trigger.hidden = false; trigger.classList.add('dq-unavailable'); badge.textContent = '数据提醒暂不可用'; trigger.title = error.message; trigger.setAttribute('aria-label', '数据提醒暂不可用,点击重试'); } } refresh(); setInterval(refresh, 30000); window.addEventListener('focus', refresh); })();