// ==UserScript==
// @name 数据导出增强版
// @namespace https://docs.scriptcat.org/
// @version 0.3.0
// @description 支持任务备份导出和全量采集题ID/序列名称,可自动翻页采集全部数据
// @author You
// @match *://*/*project*
// @grant none
// @noframes
// ==/UserScript==
(function() {
document.querySelectorAll('#ultimate-scraper-panel').forEach(panel => panel.remove());
// ================= 1. 本地存储 =================
const STORAGE_KEY = 'usc_projects_v3';
function saveToStorage() {
try {
const data = { projects, currentProjectId, mappingData };
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch(e) {}
}
function loadFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const data = JSON.parse(raw);
projects = data.projects || [];
currentProjectId = data.currentProjectId || (projects.length > 0 ? projects[0].id : null);
mappingData = data.mappingData || [];
}
} catch(e) {}
}
// ================= 2. 网络 & DOM 拦截 =================
let latestDownloadFilename = '';
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(...args) {
this._url = args[1];
return originalOpen.apply(this, args);
};
XMLHttpRequest.prototype.send = function(...args) {
this.addEventListener('load', function() {
try {
const disposition = this.getResponseHeader('Content-Disposition');
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
const matches = filenameRegex.exec(disposition);
if (matches != null && matches[1]) {
let filename = matches[1].replace(/['"]/g, '');
if (filename.startsWith("UTF-8''")) {
filename = decodeURIComponent(filename.replace("UTF-8''", ''));
}
latestDownloadFilename = filename;
}
}
} catch (e) {}
});
return originalSend.apply(this, args);
};
const originalFetch = window.fetch;
window.fetch = async function(...args) {
const response = await originalFetch.apply(this, args);
try {
const disposition = response.headers.get('Content-Disposition');
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
const matches = filenameRegex.exec(disposition);
if (matches != null && matches[1]) {
let filename = matches[1].replace(/['"]/g, '');
if (filename.startsWith("UTF-8''")) {
filename = decodeURIComponent(filename.replace("UTF-8''", ''));
}
latestDownloadFilename = filename;
}
}
} catch (e) {}
return response;
};
const originalCreateElement = document.createElement.bind(document);
document.createElement = function(tagName, options) {
const el = originalCreateElement(tagName, options);
if (tagName.toLowerCase() === 'a') {
const originalSetAttribute = el.setAttribute.bind(el);
el.setAttribute = function(name, value) {
if (name === 'download') {
latestDownloadFilename = value;
} else if (name === 'href' && !latestDownloadFilename) {
try {
const url = new URL(value, window.location.origin);
const fname = url.pathname.split('/').pop();
if (fname && fname.includes('.')) latestDownloadFilename = fname;
} catch (e) {}
}
return originalSetAttribute(name, value);
};
if (el.getAttribute('download')) {
latestDownloadFilename = el.getAttribute('download');
}
}
return el;
};
const originalOpenWindow = window.open;
window.open = function(url, ...args) {
if (url && typeof url === 'string') {
try {
const path = new URL(url, window.location.origin).pathname;
const fname = path.split('/').pop();
if (fname && fname.includes('.')) latestDownloadFilename = fname;
} catch (e) {}
}
return originalOpenWindow.call(window, url, ...args);
};
// ================= 3. 辅助函数 =================
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) { observer.disconnect(); resolve(el); }
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { observer.disconnect(); reject(new Error(`未找到元素: ${selector}`)); }, timeout);
});
}
async function clickButtonByText(text, parentSelector = 'body', timeout = 5000) {
await waitForElement(`${parentSelector} button, ${parentSelector} label`, timeout);
const allBtns = document.querySelectorAll(`${parentSelector} button, ${parentSelector} label`);
const normalizedText = text.replace(/\s+/g, '');
for (const btn of allBtns) {
const btnText = btn.innerText.replace(/\s+/g, '');
if (btnText.includes(normalizedText)) {
btn.click();
await new Promise(r => setTimeout(r, 300));
return;
}
}
throw new Error(`未找到包含文字 "${text}" 的按钮`);
}
async function clickSpecificExportButton() {
await waitForElement('button.el-button', 5000);
const buttons = document.querySelectorAll('button.el-button');
for (const btn of buttons) {
if (btn.innerText.replace(/\s+/g, '').includes('导出') && btn.querySelector('svg use[*|href="#icondaochu"]')) {
btn.scrollIntoView({ behavior: 'smooth', block: 'center' });
await new Promise(r => setTimeout(r, 300));
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
await new Promise(r => setTimeout(r, 300));
return;
}
}
throw new Error('未找到带图标的导出按钮');
}
function waitForLoadingToDisappear() {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const loaders = document.querySelectorAll('.el-loading-mask, .el-loading-spinner');
let isLoading = false;
loaders.forEach(l => { if (l.offsetParent !== null) isLoading = true; });
if (!isLoading) { clearInterval(checkInterval); resolve(); }
}, 500);
});
}
function setVueInputValue(inputEl, value) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
nativeInputValueSetter.call(inputEl, value);
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
}
async function safeClick(el) {
el.click();
await new Promise(r => setTimeout(r, 300));
}
async function goBackToParentTask() {
await waitForElement('div#tab-0[role="tab"]', 8000);
const clickTarget =
document.querySelector('#tab-0 > span > svg > use') ||
document.querySelector('#tab-0 > span.task_name') ||
document.querySelector('div#tab-0[role="tab"] .task_name');
if (!clickTarget) throw new Error('未找到返回按钮');
clickTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
await new Promise(r => setTimeout(r, 300));
['mousedown', 'mouseup', 'click'].forEach(type => {
clickTarget.dispatchEvent(new MouseEvent(type, {
bubbles: true,
cancelable: true,
view: window
}));
});
await new Promise(r => setTimeout(r, 2500));
}
function decodeFilename(name) {
if (!name) return '';
try { return decodeURIComponent(name); } catch (e) { return name; }
}
function acquireGlobalDownloadLock(lockName, ttl = 2000) {
const lockStore = window.__uscDownloadLocks || (window.__uscDownloadLocks = {});
const now = Date.now();
if (lockStore[lockName] && now - lockStore[lockName] < ttl) {
return false;
}
lockStore[lockName] = now;
return true;
}
// ================= 4. 数据模型 =================
let projects = []; // [{ id, name, tasks: [], nextTaskId }]
let currentProjectId = null;
let mappingData = []; // [[taskName, fileName, projectName], ...]
loadFromStorage();
function getCurrentProject() {
return projects.find(p => p.id === currentProjectId) || null;
}
// ================= 5. UI 面板 =================
const panelHTML = `
状态:等待开始
进度: 0/0
已采集: 0 条
📊 选中任务统计
总题数:0
已标注:0
已审核:0
已质检:0
已验收:0
状态:等待开始
进度: 0/0
已采集: 0 条
说明:选择任务后点击开始,将自动进入任务详情,采集所有页面的题ID和序列名称数据,完成后自动返回。
进度: 0/0
待命
`;
document.body.insertAdjacentHTML('beforeend', panelHTML);
// ================= 6. DOM 引用与事件绑定 =================
const panel = document.getElementById('ultimate-scraper-panel');
const header = document.getElementById('usc-header');
const minimizeBtn = document.getElementById('usc-minimize');
const bodyDiv = document.getElementById('usc-body');
const logArea = document.getElementById('usc-log-area');
const taskListDOM = document.getElementById('usc-task-list');
const startBtn = document.getElementById('usc-start');
const projectSelect = document.getElementById('usc-project-select');
// 标签切换
document.querySelectorAll('.usc-tab').forEach(tab => {
// 添加hover效果
tab.addEventListener('mouseenter', function() {
if (!this.classList.contains('active')) {
this.style.background = 'rgba(255,255,255,0.15)';
}
});
tab.addEventListener('mouseleave', function() {
if (!this.classList.contains('active')) {
this.style.background = 'transparent';
}
});
// 点击切换
tab.addEventListener('click', function() {
// 移除所有active样式
document.querySelectorAll('.usc-tab').forEach(t => {
t.classList.remove('active');
t.style.background = 'transparent';
t.style.boxShadow = 'none';
});
// 添加当前active样式
this.classList.add('active');
this.style.background = 'rgba(255,255,255,0.25)';
this.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
// 切换内容显示
document.querySelectorAll('.usc-tab-content').forEach(content => {
content.style.display = content.id === `tab-${this.dataset.tab}` ? 'block' : 'none';
});
// 刷新任务下拉列表
if (this.dataset.tab === 'topic') {
refreshTopicTaskCheckboxList();
}
// 刷新数据看板
if (this.dataset.tab === 'view') {
renderViewList();
}
// API导出 - 尝试自动填充 access-key
if (this.dataset.tab === 'api') {
const akInput = document.getElementById('api-ak');
if (akInput && !akInput.value) {
// 试着从页面已有的请求中找 access-key
const scripts = document.querySelectorAll('script');
for (const s of scripts) {
const m = s.textContent.match(/access-key["']?\s*:\s*["']([^"']+)/);
if (m) { akInput.value = m[1]; break; }
}
}
}
});
});
// 最小化/还原
let isMinimized = false;
minimizeBtn.addEventListener('click', () => {
if (isMinimized) {
bodyDiv.style.display = 'block';
panel.style.width = '500px';
panel.style.height = '';
minimizeBtn.innerText = '–';
isMinimized = false;
} else {
bodyDiv.style.display = 'none';
panel.style.width = '200px';
minimizeBtn.innerText = '□';
isMinimized = true;
}
});
// 关闭
document.getElementById('usc-close').addEventListener('click', () => panel.remove());
// 拖动
let isDragging = false, dragOffsetX, dragOffsetY;
header.addEventListener('mousedown', function(e) {
if (e.target.id === 'usc-minimize' || e.target.id === 'usc-close' || e.target.classList.contains('usc-tab')) return;
isDragging = true;
const rect = panel.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
panel.style.transition = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
panel.style.left = Math.min(Math.max(0, e.clientX - dragOffsetX), window.innerWidth - panel.offsetWidth) + 'px';
panel.style.top = Math.min(Math.max(0, e.clientY - dragOffsetY), window.innerHeight - 50) + 'px';
panel.style.right = 'auto';
});
document.addEventListener('mouseup', function() {
if (isDragging) {
isDragging = false;
panel.style.transition = '';
}
});
// 日志输出
function addLog(msg, type = 'info') {
const colors = { info: '#cccccc', success: '#4EC9B0', error: '#F56C6C', warn: '#E6A23C' };
logArea.innerHTML += `${new Date().toLocaleTimeString()} - ${msg}
`;
logArea.scrollTop = logArea.scrollHeight;
}
// ================= 7. 备份任务核心功能(保持原有逻辑) =================
function updateProgress() {
const project = getCurrentProject();
const total = project ? project.tasks.length : 0;
const successCount = project ? project.tasks.filter(t => t.status === 'success').length : 0;
document.getElementById('usc-progress-bar').style.width = total ? ((successCount / total) * 100).toFixed(0) + '%' : '0%';
document.getElementById('usc-progress-text').innerText = `进度: ${successCount}/${total}`;
}
function renderProjectSelect() {
projectSelect.innerHTML = '';
projects.forEach(p => {
const option = document.createElement('option');
option.value = p.id;
option.textContent = p.name;
if (p.id === currentProjectId) option.selected = true;
projectSelect.appendChild(option);
});
if (!currentProjectId && projects.length > 0) {
currentProjectId = projects[0].id;
projectSelect.value = currentProjectId;
}
renderTaskList();
updateProgress();
}
function renderTaskList() {
const project = getCurrentProject();
taskListDOM.innerHTML = '';
if (!project) return;
project.tasks.forEach(task => {
const statusStyles = {
pending: 'color: #606266;',
running: 'color: #E6A23C; font-weight: bold;',
success: 'color: #67C23A; text-decoration: line-through;',
error: 'color: #F56C6C;'
};
const statusTexts = { pending: '', running: '执行中...', success: '✅ 已完成', error: '❌ 失败' };
const toggleBtn = (task.status === 'success' || task.status === 'error')
? ``
: ``;
taskListDOM.insertAdjacentHTML('beforeend', `
${task.name}
${statusTexts[task.status]}
${toggleBtn}
`);
});
// 绑定状态切换事件
document.querySelectorAll('.usc-toggle-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const id = Number(this.dataset.id);
const project = getCurrentProject();
if (!project) return;
const task = project.tasks.find(t => t.id === id);
if (!task) return;
if (task.status === 'success' || task.status === 'error') {
task.status = 'pending';
mappingData = mappingData.filter(row => row[0] !== task.name);
} else {
task.status = 'success';
if (!task.fileName) task.fileName = `手动标记_${Date.now()}.zip`;
if (!mappingData.some(row => row[0] === task.name)) {
mappingData.push([task.name, task.fileName, project.name]);
}
}
renderTaskList();
updateProgress();
saveToStorage();
});
});
updateProgress();
}
// 项目选择
projectSelect.addEventListener('change', () => {
currentProjectId = Number(projectSelect.value);
renderTaskList();
updateProgress();
saveToStorage();
});
// 新建项目
document.getElementById('usc-add-project-btn').addEventListener('click', () => {
const name = prompt('请输入项目名称:');
if (!name) return;
const newId = projects.length > 0 ? Math.max(...projects.map(p => p.id)) + 1 : 1;
projects.push({ id: newId, name, tasks: [], nextTaskId: 1 });
currentProjectId = newId;
renderProjectSelect();
saveToStorage();
});
// 删除项目
document.getElementById('usc-del-project-btn').addEventListener('click', () => {
if (!confirm('确定删除当前项目及其所有任务吗?')) return;
projects = projects.filter(p => p.id !== currentProjectId);
currentProjectId = projects.length > 0 ? projects[0].id : null;
renderProjectSelect();
saveToStorage();
});
// 添加任务
document.getElementById('usc-add-task-btn').addEventListener('click', () => {
const project = getCurrentProject();
if (!project) return alert('请先创建/选择一个项目');
const input = document.getElementById('usc-new-task-input');
const name = input.value.trim();
if (!name) return;
project.tasks.push({ id: project.nextTaskId++, name, status: 'pending', fileName: '' });
input.value = '';
renderTaskList();
saveToStorage();
addLog(`已添加任务: ${name}`, 'success');
});
// 导入 CSV
document.getElementById('usc-import-csv-btn').addEventListener('click', () => {
document.getElementById('usc-csv-upload').click();
});
document.getElementById('usc-csv-upload').addEventListener('change', function(e) {
const project = getCurrentProject();
if (!project) return alert('请先选择一个项目');
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(evt) {
const text = evt.target.result.replace(/^\uFEFF/, '');
const lines = text.split(/\r?\n/).filter(line => line.trim() !== '');
let added = 0;
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',');
const name = cols[0].replace(/"/g, '').trim();
if (name) {
project.tasks.push({ id: project.nextTaskId++, name, status: 'pending', fileName: '' });
added++;
}
}
if (added) { renderTaskList(); saveToStorage(); addLog(`导入 ${added} 个任务`, 'success'); }
else alert('CSV解析失败');
};
reader.readAsText(file);
});
// 全选/反选
document.getElementById('usc-select-all').addEventListener('click', () => {
document.querySelectorAll('.usc-task-check:not(:disabled)').forEach(cb => cb.checked = true);
});
document.getElementById('usc-select-reverse').addEventListener('click', () => {
document.querySelectorAll('.usc-task-check:not(:disabled)').forEach(cb => cb.checked = !cb.checked);
});
// 执行备份(原有逻辑保留,但为了代码简洁省略详细注释,保持原有功能)
startBtn.addEventListener('click', async () => {
const project = getCurrentProject();
if (!project) return alert('请选择一个项目');
const checkedBoxes = document.querySelectorAll('.usc-task-check:checked');
if (checkedBoxes.length === 0) return alert('请勾选任务');
startBtn.disabled = true;
startBtn.innerText = '⏳ 执行中...';
const selectedTaskIds = Array.from(checkedBoxes).map(cb => Number(cb.dataset.id));
const tasksToRun = project.tasks.filter(t => selectedTaskIds.includes(t.id) && t.status !== 'success');
let successCount = project.tasks.filter(t => t.status === 'success').length;
for (const task of tasksToRun) {
let hasReturnedToParent = false;
task.status = 'running';
renderTaskList();
document.getElementById('usc-status').innerText = '处理中';
addLog(`---------- 开始处理: ${task.name} ----------`, 'info');
try {
// 1. 搜索
addLog('1. 搜索任务...', 'info');
const searchInput = await waitForElement('.search_list input.el-input__inner');
setVueInputValue(searchInput, task.name);
await clickButtonByText('搜索', '.search_list');
await new Promise(r => setTimeout(r, 2500));
// 2. 进入详情
addLog('2. 点击进入任务详情...', 'info');
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr.el-table__row');
let entered = false;
const normalize = s => (s || '').replace(/[\s\u00A0\u3000]+/g, '').trim();
const targetName = normalize(task.name);
for (const row of rows) {
let rowName = '';
try {
const vue = row.__vue__;
if (vue && vue.row) {
rowName = normalize(vue.row.task_name || vue.row.name || '');
}
} catch(e) {}
if (rowName === targetName) {
addLog(`Vue行数据匹配命中: "${rowName}"`, 'success');
const nameSpan = row.querySelector('td.organizationStyle span');
const nameTd = row.querySelector('td.organizationStyle');
if (nameSpan) { await safeClick(nameSpan); entered = true; break; }
if (nameTd) { await safeClick(nameTd); entered = true; break; }
}
}
if (!entered) {
addLog('尝试表格数据匹配...', 'warn');
try {
const tableEl = document.querySelector('.el-table');
if (tableEl && tableEl.__vue__) {
let vm = tableEl.__vue__;
while (vm) {
if (vm.$data && vm.$data.tableOptions && Array.isArray(vm.$data.tableOptions.data)) {
const tableData = vm.$data.tableOptions.data;
for (let i = 0; i < tableData.length; i++) {
const rd = tableData[i];
const rowName = normalize(rd.task_name || rd.name || '');
if (rowName === targetName && i < rows.length) {
addLog(`表格数据匹配命中(索引${i}): "${rowName}"`, 'success');
const row = rows[i];
const nameSpan = row.querySelector('td.organizationStyle span');
const nameTd = row.querySelector('td.organizationStyle');
if (nameSpan) { await safeClick(nameSpan); entered = true; break; }
if (nameTd) { await safeClick(nameTd); entered = true; break; }
}
}
break;
}
vm = vm.$parent;
}
}
} catch(e) {
addLog(`表格数据匹配异常: ${e.message}`, 'warn');
}
}
if (!entered && rows.length === 1) {
addLog('仅一行搜索结果,直接点击', 'warn');
const row = rows[0];
const nameSpan = row.querySelector('td.organizationStyle span');
const nameTd = row.querySelector('td.organizationStyle');
if (nameSpan) { await safeClick(nameSpan); entered = true; }
else if (nameTd) { await safeClick(nameTd); entered = true; }
}
if (!entered) throw new Error('搜索结果中未找到该任务');
// 3. 等待详情页加载
addLog('3. 等待详情页加载...', 'info');
try { await waitForElement('div#tab-0', 8000); } catch(e) { throw new Error('未成功进入任务详情页'); }
await new Promise(r => setTimeout(r, 3000));
// 4. 点击导出按钮
addLog('4. 点击导出按钮...', 'info');
await clickSpecificExportButton();
await new Promise(r => setTimeout(r, 2000));
// 5. 选择“原文件带JSON”
addLog('5. 选择原文件带JSON...', 'info');
const radioLabels = document.querySelectorAll('.drawer_box .el-radio-group label');
let radioClicked = false;
for (const label of radioLabels) {
if (label.innerText.replace(/\s+/g, '').includes('原文件带JSON')) {
await safeClick(label);
radioClicked = true;
break;
}
}
if (!radioClicked) throw new Error('未找到该选项');
await new Promise(r => setTimeout(r, 500));
// 6. 确认导出
addLog('6. 确认导出...', 'info');
await clickButtonByText('导出', '.drawer__footer');
await new Promise(r => setTimeout(r, 2000));
await waitForLoadingToDisappear();
await new Promise(r => setTimeout(r, 2000));
// 7. 等待下载管理页面
addLog('7. 等待进入下载管理页面...', 'info');
try { await waitForElement('.el-table__header', 15000); } catch(e) { throw new Error('未检测到下载管理页面'); }
// 8. 监听打包状态
addLog('8. 等待打包完成...', 'info');
const MAX_WAIT_MS = 300000;
const POLL_INTERVAL_MS = 3000;
const waitStart = Date.now();
let packDone = false;
function getFirstRowStatus() {
const firstRow = document.querySelector('.el-table__body-wrapper tbody tr.el-table__row');
if (!firstRow) return '';
const allSpans = firstRow.querySelectorAll('.cell span');
for (const sp of allSpans) {
const t = sp.innerText.trim();
if (t === '已完成' || t === '处理中') return t;
}
const svgUse = firstRow.querySelector('svg use');
if (svgUse) {
const href = svgUse.getAttribute('xlink:href') || svgUse.getAttribute('href') || '';
if (href.includes('iconyitongguo')) return '已完成';
if (href.includes('iconjinhangzhong')) return '处理中';
}
return '';
}
while (Date.now() - waitStart < MAX_WAIT_MS) {
const status = getFirstRowStatus();
addLog(`第一行状态: ${status || '未知'}`, 'info');
if (status === '已完成') {
packDone = true;
break;
}
const elapsed = Math.round((Date.now() - waitStart) / 1000);
addLog(`打包中... 已等待${elapsed}秒`, 'info');
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
}
if (!packDone) {
addLog('打包超时(5分钟),尝试继续下载...', 'warn');
} else {
addLog('打包已完成!', 'success');
}
await new Promise(r => setTimeout(r, 1000));
// 9. 点击第一行的下载按钮
addLog('9. 查找下载按钮...', 'info');
let downloadBtn = null;
let attempt = 0;
while (attempt < 10 && !downloadBtn) {
const firstRow = document.querySelector('.el-table__body-wrapper tbody tr.el-table__row');
if (firstRow) {
const btn = firstRow.querySelector('button');
if (btn && btn.innerText.replace(/\s+/g, '') === '下载') {
const color = btn.style.color;
if (color && color !== 'rgb(192, 196, 200)') {
downloadBtn = btn;
}
}
}
if (!downloadBtn) {
await new Promise(r => setTimeout(r, 2000));
attempt++;
}
}
if (!downloadBtn) throw new Error('未找到可用下载按钮');
downloadBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
await new Promise(r => setTimeout(r, 300));
await safeClick(downloadBtn);
addLog('下载按钮已触发', 'success');
// 10. 捕获文件名
addLog('10. 捕获文件名...', 'info');
await new Promise(r => setTimeout(r, 2000));
if (!latestDownloadFilename) {
const allLinks = document.querySelectorAll('a[download]');
for (const link of allLinks) {
const d = link.getAttribute('download');
if (d) { latestDownloadFilename = d; break; }
}
}
let finalFilename = decodeFilename(latestDownloadFilename) || `未拦截到文件名_${Date.now()}.zip`;
task.fileName = finalFilename;
mappingData.push([task.name, finalFilename, project.name]);
// 11. 返回上级
addLog('11. 返回上级...', 'info');
await goBackToParentTask();
hasReturnedToParent = true;
task.status = 'success';
successCount++;
addLog(`✅ 完成: ${task.name} -> ${finalFilename}`, 'success');
saveToStorage();
} catch (err) {
task.status = 'error';
addLog(`❌ 失败: ${task.name} | ${err.message}`, 'error');
saveToStorage();
}
renderTaskList();
updateProgress();
try {
if (!hasReturnedToParent) {
await goBackToParentTask();
}
} catch (e) {}
}
document.getElementById('usc-status').innerText = '批次完成';
document.getElementById('usc-status').style.color = '#67C23A';
startBtn.disabled = false;
startBtn.innerText = '▶ 执行选中任务';
addLog('🎉 当前勾选任务执行完毕!', 'success');
saveToStorage();
});
// 导出映射表
document.getElementById('usc-export-map').addEventListener('click', () => {
const project = getCurrentProject();
if (!project) return alert('无项目');
const data = mappingData.filter(row => row[2] === project.name);
if (data.length === 0) return alert('无映射数据');
let csvContent = "\uFEFF任务名称,压缩包名称\n";
data.forEach(row => csvContent += `"${row[0]}","${row[1]}"\n`);
const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }));
link.download = `${project.name}_映射表.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// 映射表弹窗
const mapDialog = document.getElementById('usc-map-dialog');
const mapTbody = document.getElementById('usc-map-tbody');
const mapEmpty = document.getElementById('usc-map-empty');
function getProjectMappingIndices() {
const project = getCurrentProject();
if (!project) return [];
const indices = [];
mappingData.forEach((row, i) => { if (row[2] === project.name) indices.push(i); });
return indices;
}
function renderMapTable() {
const project = getCurrentProject();
const data = project ? mappingData.filter(row => row[2] === project.name) : [];
mapTbody.innerHTML = '';
mapEmpty.style.display = data.length === 0 ? 'block' : 'none';
data.forEach((row, idx) => {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #ebeef5';
tr.innerHTML = `
${idx + 1} |
${row[0]} |
${row[1]} |
|
`;
mapTbody.appendChild(tr);
});
// 双击单元格编辑(简化版)
mapTbody.querySelectorAll('.usc-map-cell').forEach(td => {
td.addEventListener('dblclick', function() {
if (this.querySelector('input')) return;
const field = this.dataset.field;
const idx = Number(this.dataset.idx);
const oldVal = field === 'index' ? String(idx + 1) : this.innerText;
const input = document.createElement('input');
input.type = 'text';
input.value = oldVal;
input.style.cssText = 'width: 100%; padding: 2px 4px; font-size: 13px; border: 1px solid #409EFF; border-radius: 3px; outline: none; box-sizing: border-box;';
this.innerText = '';
this.appendChild(input);
input.focus();
input.select();
const commitEdit = () => {
const globalIndices = getProjectMappingIndices();
const newVal = input.value.trim();
if (field === 'index') {
const newIdx = parseInt(newVal) - 1;
if (isNaN(newIdx) || newIdx < 0 || newIdx >= data.length || newIdx === idx) {
renderMapTable(); return;
}
const gIdx = globalIndices[idx];
const row = mappingData.splice(gIdx, 1)[0];
const newGIdx = globalIndices[newIdx] !== undefined ? globalIndices[newIdx] : globalIndices[globalIndices.length - 1] + 1;
mappingData.splice(newGIdx, 0, row);
} else if (field === 'task') {
if (newVal && newVal !== oldVal) mappingData[globalIndices[idx]][0] = newVal;
} else if (field === 'file') {
if (newVal && newVal !== oldVal) mappingData[globalIndices[idx]][1] = newVal;
}
saveToStorage();
renderMapTable();
};
input.addEventListener('blur', commitEdit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { input.blur(); }
if (e.key === 'Escape') { input.removeEventListener('blur', commitEdit); renderMapTable(); }
});
});
});
// 编辑按钮
mapTbody.querySelectorAll('.usc-map-edit-btn').forEach(btn => {
btn.addEventListener('click', function() {
const idx = Number(this.dataset.idx);
const row = mapTbody.children[idx];
if (!row || row.dataset.editing === 'true') return;
row.dataset.editing = 'true';
const globalIndices = getProjectMappingIndices();
const gIdx = globalIndices[idx];
const mapRow = mappingData[gIdx];
const cells = row.querySelectorAll('.usc-map-cell');
const indexInput = document.createElement('input');
indexInput.type = 'number';
indexInput.value = idx + 1;
indexInput.min = 1;
indexInput.max = data.length;
indexInput.style.cssText = 'width: 50px; padding: 2px 4px; font-size: 13px; border: 1px solid #409EFF; border-radius: 3px;';
cells[0].innerText = '';
cells[0].appendChild(indexInput);
const taskInput = document.createElement('input');
taskInput.type = 'text';
taskInput.value = mapRow[0];
taskInput.style.cssText = 'width: 100%; padding: 2px 4px; font-size: 13px; border: 1px solid #409EFF; border-radius: 3px; box-sizing: border-box;';
cells[1].innerText = '';
cells[1].appendChild(taskInput);
const fileInput = document.createElement('input');
fileInput.type = 'text';
fileInput.value = mapRow[1];
fileInput.style.cssText = 'width: 100%; padding: 2px 4px; font-size: 13px; border: 1px solid #409EFF; border-radius: 3px; box-sizing: border-box;';
cells[2].innerText = '';
cells[2].appendChild(fileInput);
const opTd = row.querySelector('td:last-child');
opTd.innerHTML = `
`;
opTd.querySelector('.usc-map-save-btn').addEventListener('click', function() {
const newIdx = parseInt(indexInput.value) - 1;
const newTask = taskInput.value.trim();
const newFile = fileInput.value.trim();
if (!newTask || !newFile) return alert('任务名称和压缩包名称不能为空');
mappingData[gIdx][0] = newTask;
mappingData[gIdx][1] = newFile;
if (!isNaN(newIdx) && newIdx >= 0 && newIdx < data.length && newIdx !== idx) {
const movedRow = mappingData.splice(gIdx, 1)[0];
const recalcIndices = getProjectMappingIndices();
const targetGIdx = newIdx < recalcIndices.length ? recalcIndices[newIdx] : recalcIndices[recalcIndices.length - 1] + 1;
mappingData.splice(targetGIdx, 0, movedRow);
}
saveToStorage();
renderMapTable();
});
opTd.querySelector('.usc-map-cancel-btn').addEventListener('click', () => renderMapTable());
taskInput.focus();
});
});
// 删除事件
mapTbody.querySelectorAll('.usc-map-del-btn').forEach(btn => {
btn.addEventListener('click', function() {
const project = getCurrentProject();
if (!project) return;
const delIdx = Number(this.dataset.idx);
const projectData = mappingData.filter(row => row[2] === project.name);
if (delIdx < 0 || delIdx >= projectData.length) return;
const delRow = projectData[delIdx];
const globalIdx = mappingData.indexOf(delRow);
if (globalIdx !== -1) {
mappingData.splice(globalIdx, 1);
saveToStorage();
renderMapTable();
}
});
});
}
document.getElementById('usc-view-map').addEventListener('click', () => {
if (!getCurrentProject()) return alert('请先选择一个项目');
renderMapTable();
mapDialog.style.display = 'flex';
});
document.getElementById('usc-map-dialog-close').addEventListener('click', () => {
mapDialog.style.display = 'none';
});
mapDialog.addEventListener('click', (e) => {
if (e.target === mapDialog) mapDialog.style.display = 'none';
});
document.getElementById('usc-map-add-btn').addEventListener('click', () => {
const project = getCurrentProject();
if (!project) return;
const taskInput = document.getElementById('usc-map-add-task');
const fileInput = document.getElementById('usc-map-add-file');
const taskName = taskInput.value.trim();
const fileName = fileInput.value.trim();
if (!taskName) return alert('请输入任务名称');
if (!fileName) return alert('请输入压缩包名称');
mappingData.push([taskName, fileName, project.name]);
taskInput.value = '';
fileInput.value = '';
saveToStorage();
renderMapTable();
});
// ================= 8. 全量采集器(任务列表)原有功能保留 =================
let collectAllData = [];
let isCollecting = false;
let isCollectDownloading = false;
function getVueTableData() {
const tableEl = document.querySelector('.el-table');
if (!tableEl || !tableEl.__vue__) return [];
let vm = tableEl.__vue__;
while (vm.$parent) {
if (vm.$data && vm.$data.tableOptions && Array.isArray(vm.$data.tableOptions.data)) {
return vm.$data.tableOptions.data;
}
vm = vm.$parent;
}
return [];
}
function collectClickAndWait(element) {
return new Promise((resolve) => {
element.click();
setTimeout(resolve, 1500);
});
}
document.getElementById('collect-start').addEventListener('click', async function() {
if (isCollecting) return;
isCollecting = true;
collectAllData = [];
this.disabled = true;
this.innerText = '正在采集中...';
document.getElementById('collect-download').style.display = 'none';
const statusEl = document.getElementById('collect-status');
const progressBar = document.getElementById('collect-progress-bar');
const progressText = document.getElementById('collect-progress-text');
const dataCount = document.getElementById('collect-data-count');
statusEl.innerText = '准备翻至第一页...';
statusEl.style.color = '#E6A23C';
try {
const totalText = document.querySelector('.el-pagination__total').innerText;
const totalItems = parseInt(totalText.match(/\d+/)[0]);
const pageSize = 20;
const totalPages = Math.ceil(totalItems / pageSize) || 1;
const firstPageBtn = document.querySelector('.el-pager li.number');
if (firstPageBtn) await collectClickAndWait(firstPageBtn);
statusEl.innerText = '正在采集...';
statusEl.style.color = '#409EFF';
for (let page = 1; page <= totalPages; page++) {
const pageData = getVueTableData();
if (pageData.length > 0) collectAllData = collectAllData.concat(pageData);
const percent = (page / totalPages * 100).toFixed(0) + '%';
progressBar.style.width = percent;
progressText.innerText = `进度: ${page}/${totalPages}`;
dataCount.innerText = `已采集: ${collectAllData.length} 条`;
const nextBtn = document.querySelector('.el-pagination .btn-next:not([disabled])');
if (nextBtn && page < totalPages) {
await collectClickAndWait(nextBtn);
} else {
break;
}
}
// 去重
const uniqueMap = new Map();
collectAllData.forEach(item => {
if (item.task_key && !uniqueMap.has(item.task_key)) {
uniqueMap.set(item.task_key, item);
}
});
collectAllData = Array.from(uniqueMap.values());
statusEl.innerText = '采集完成!';
statusEl.style.color = '#67C23A';
dataCount.innerText = `去重后: ${collectAllData.length} 条`;
document.getElementById('collect-download').style.display = 'block';
// 刷新数据看板
addLog('全量采集完成,可切换到「数据看板」标签查看', 'success');
} catch (err) {
statusEl.innerText = '采集出错';
statusEl.style.color = '#F56C6C';
} finally {
this.disabled = false;
this.innerText = '重新采集';
isCollecting = false;
}
});
document.getElementById('collect-download').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (isCollectDownloading) return;
if (!acquireGlobalDownloadLock('collect-download')) return;
if (collectAllData.length === 0) return alert('没有数据!');
isCollectDownloading = true;
const downloadBtn = e.currentTarget;
downloadBtn.disabled = true;
const headers = ['任务名称', '任务key', '标注类型', '总题数', '标注进度', '审核进度', '质检进度', '验收进度', '创建人', '创建时间', '任务状态'];
let csvContent = "\uFEFF" + headers.join(',') + '\n';
const formatVal = (val) => {
if (val === null || val === undefined) val = '';
val = String(val);
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
};
collectAllData.forEach(item => {
const row = [
item.task_name, item.task_key, item.task_type, item.total_num,
item.label_progress, item.check_progress, item.inspect_progress,
item.accept_progress, item.creator, item.created_at,
item.online_label || (item.switch ? '上线' : '下线')
].map(formatVal);
csvContent += row.join(',') + '\n';
});
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }));
link.href = objectUrl;
link.download = '全量完整任务数据.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => {
URL.revokeObjectURL(objectUrl);
downloadBtn.disabled = false;
isCollectDownloading = false;
}, 300);
});
// ================= 9. 题ID采集器(新增功能) =================
let topicCollectorData = [];
let isTopicCollecting = false;
let isTopicDownloading = false;
function refreshTopicTaskCheckboxList() {
const project = getCurrentProject();
const container = document.getElementById('topic-task-checkbox-list');
if (!container) return;
if (!project || !project.tasks || project.tasks.length === 0) {
container.innerHTML = '暂无任务,请先在备份任务中添加
';
return;
}
container.innerHTML = '';
project.tasks.forEach(task => {
const div = document.createElement('div');
div.style.marginBottom = '8px';
div.innerHTML = `
`;
container.appendChild(div);
});
}
function getSelectedTopicTasks() {
const checkboxes = document.querySelectorAll('.topic-task-checkbox:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
// 从详情页表格中采集题ID和序列名称(自动翻页)
async function collectTopicAndSequence(taskName, onProgress) {
const collected = [];
// 等待数据详情表格加载
await waitForElement('.dataDetail .el-table', 10000);
// 获取分页信息
const getPaginationInfo = () => {
const totalSpan = document.querySelector('.el-pagination__total');
let total = 0;
if (totalSpan) {
const match = totalSpan.innerText.match(/\d+/);
if (match) total = parseInt(match[0], 10);
}
const pageSize = 20; // 默认每页20条
const totalPages = Math.ceil(total / pageSize) || 1;
return { total, totalPages, pageSize };
};
// 从当前表格提取数据
const extractCurrentPageData = () => {
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr.el-table__row');
const pageData = [];
for (const row of rows) {
// 题ID(第2列)
const topicIdCell = row.querySelector('td:nth-child(2) .cell');
const topicId = topicIdCell ? topicIdCell.innerText.trim() : '';
// 序列名称(第3列)
const seqNameCell = row.querySelector('td:nth-child(3) .cell');
const seqName = seqNameCell ? seqNameCell.innerText.trim() : '';
// 工序状态(第4-7列)
const s1 = row.querySelector('td:nth-child(4) .cell');
const s2 = row.querySelector('td:nth-child(5) .cell');
const s3 = row.querySelector('td:nth-child(6) .cell');
const s4 = row.querySelector('td:nth-child(7) .cell');
if (topicId) {
pageData.push({ 题ID: topicId, 序列名称: seqName, 标注工序: s1 ? s1.innerText.trim() : '', 审核工序: s2 ? s2.innerText.trim() : '', 质检工序: s3 ? s3.innerText.trim() : '', 验收工序: s4 ? s4.innerText.trim() : '' });
}
}
return pageData;
};
// 点击翻页
const goToNextPage = async () => {
const nextBtn = document.querySelector('.el-pagination .btn-next:not([disabled])');
if (nextBtn) {
nextBtn.click();
await new Promise(r => setTimeout(r, 2000));
await waitForLoadingToDisappear();
await new Promise(r => setTimeout(r, 1000));
return true;
}
return false;
};
// 确保在第一页
const firstPageBtn = document.querySelector('.el-pager li.number');
if (firstPageBtn && !firstPageBtn.classList.contains('active')) {
firstPageBtn.click();
await new Promise(r => setTimeout(r, 2000));
await waitForLoadingToDisappear();
}
let { totalPages } = getPaginationInfo();
if (totalPages === 0) totalPages = 1;
for (let page = 1; page <= totalPages; page++) {
const pageData = extractCurrentPageData();
collected.push(...pageData);
if (onProgress) {
onProgress(page, totalPages, collected.length);
}
if (page < totalPages) {
const hasNext = await goToNextPage();
if (!hasNext) break;
// 重新获取总页数(可能动态变化)
const newInfo = getPaginationInfo();
if (newInfo.totalPages !== totalPages) {
totalPages = newInfo.totalPages;
}
}
}
return collected;
}
// 进入任务详情并采集
async function enterTaskAndCollect(taskName, onStatus) {
onStatus('正在搜索任务...');
// 搜索任务
const searchInput = await waitForElement('.search_list input.el-input__inner');
setVueInputValue(searchInput, taskName);
await clickButtonByText('搜索', '.search_list');
await new Promise(r => setTimeout(r, 2500));
onStatus('正在查找任务...');
const rows = document.querySelectorAll('.el-table__body-wrapper tbody tr.el-table__row');
let entered = false;
const normalize = s => (s || '').replace(/[\s\u00A0\u3000]+/g, '').trim();
const targetName = normalize(taskName);
// 匹配任务行
for (const row of rows) {
let rowName = '';
try {
const vue = row.__vue__;
if (vue && vue.row) {
rowName = normalize(vue.row.task_name || vue.row.name || '');
}
} catch(e) {}
if (rowName === targetName) {
const nameSpan = row.querySelector('td.organizationStyle span');
const nameTd = row.querySelector('td.organizationStyle');
if (nameSpan) { await safeClick(nameSpan); entered = true; break; }
if (nameTd) { await safeClick(nameTd); entered = true; break; }
}
}
if (!entered && rows.length === 1) {
const row = rows[0];
const nameSpan = row.querySelector('td.organizationStyle span');
const nameTd = row.querySelector('td.organizationStyle');
if (nameSpan) { await safeClick(nameSpan); entered = true; }
else if (nameTd) { await safeClick(nameTd); entered = true; }
}
if (!entered) {
throw new Error('未找到任务入口');
}
onStatus('进入任务详情页...');
try { await waitForElement('div#tab-0', 8000); } catch(e) { throw new Error('未成功进入任务详情页'); }
await new Promise(r => setTimeout(r, 2000));
// 切换到“数据详情”标签页
onStatus('切换到数据详情标签...');
const dataDetailTab = document.querySelector('#tab-first');
if (dataDetailTab && !dataDetailTab.classList.contains('is-active')) {
dataDetailTab.click();
await new Promise(r => setTimeout(r, 1500));
}
onStatus('开始采集题ID和序列名称...');
const collected = await collectTopicAndSequence(taskName, (page, total, count) => {
onStatus(`采集进度: ${page}/${total} 页, 已采集 ${count} 条`);
});
onStatus('采集完成,准备返回...');
await goBackToParentTask();
return collected;
}
document.getElementById('topic-start').addEventListener('click', async function() {
if (isTopicCollecting) return;
const selectedTasks = getSelectedTopicTasks();
if (selectedTasks.length === 0) {
alert('请至少选择一个任务');
return;
}
isTopicCollecting = true;
this.disabled = true;
this.innerText = '批量采集中...';
document.getElementById('topic-download').style.display = 'none';
topicCollectorData = [];
const statusEl = document.getElementById('topic-status');
const progressBar = document.getElementById('topic-progress-bar');
const progressText = document.getElementById('topic-progress-text');
const dataCount = document.getElementById('topic-data-count');
statusEl.innerText = '准备开始批量采集...';
statusEl.style.color = '#E6A23C';
let totalCollected = 0;
let successCount = 0;
let failCount = 0;
for (let i = 0; i < selectedTasks.length; i++) {
const taskName = selectedTasks[i];
const taskProgress = (i / selectedTasks.length) * 100;
progressBar.style.width = `${taskProgress}%`;
progressText.innerText = `进度: ${i}/${selectedTasks.length} 个任务`;
statusEl.innerText = `[${i+1}/${selectedTasks.length}] 正在采集: ${taskName}`;
addLog(`---------- 开始采集任务: ${taskName} ----------`, 'info');
try {
const onStatus = (msg) => {
statusEl.innerText = `[${i+1}/${selectedTasks.length}] ${taskName}: ${msg}`;
};
const collected = await enterTaskAndCollect(taskName, onStatus);
topicCollectorData.push({
taskName: taskName,
data: collected
});
totalCollected += collected.length;
successCount++;
addLog(`✅ 任务“${taskName}”采集完成,共 ${collected.length} 条数据`, 'success');
} catch (err) {
failCount++;
addLog(`❌ 任务“${taskName}”采集失败: ${err.message}`, 'error');
}
// 任务之间等待一下
if (i < selectedTasks.length - 1) {
await new Promise(r => setTimeout(r, 2000));
}
}
// 完成
progressBar.style.width = '100%';
progressText.innerText = `进度: ${selectedTasks.length}/${selectedTasks.length} 个任务`;
statusEl.innerText = `批量采集完成!成功: ${successCount}, 失败: ${failCount}, 总数据: ${totalCollected} 条`;
statusEl.style.color = successCount > 0 ? '#67C23A' : '#F56C6C';
dataCount.innerText = `已采集: ${totalCollected} 条 (来自 ${successCount} 个任务)`;
if (totalCollected > 0) {
document.getElementById('topic-download').style.display = 'block';
}
this.disabled = false;
this.innerText = '开始采集题ID/序列名';
isTopicCollecting = false;
addLog(`🎉 批量采集完成!成功: ${successCount}, 失败: ${failCount}`, successCount > 0 ? 'success' : 'warn');
});
document.getElementById('topic-download').addEventListener('click', function(e) {
e.preventDefault();
if (isTopicDownloading) return;
if (!acquireGlobalDownloadLock('topic-download')) return;
if (topicCollectorData.length === 0) {
alert('没有数据可导出,请先执行采集');
return;
}
isTopicDownloading = true;
const btn = this;
btn.disabled = true;
const headers = ['任务名称', '题ID', '序列名称', '标注工序', '审核工序', '质检工序', '验收工序'];
let csvContent = "\uFEFF" + headers.join(',') + '\n';
const formatVal = (val) => {
if (val === null || val === undefined) val = '';
val = String(val);
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
};
topicCollectorData.forEach(taskItem => {
taskItem.data.forEach(item => {
csvContent += `${formatVal(taskItem.taskName)},${formatVal(item.题ID)},${formatVal(item.序列名称)},${formatVal(item.标注工序)},${formatVal(item.审核工序)},${formatVal(item.质检工序)},${formatVal(item.验收工序)}\n`;
});
});
const link = document.createElement('a');
const objectUrl = URL.createObjectURL(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }));
link.href = objectUrl;
const now = new Date();
const timestamp = `${now.getFullYear()}${(now.getMonth()+1).toString().padStart(2,'0')}${now.getDate().toString().padStart(2,'0')}_${now.getHours().toString().padStart(2,'0')}${now.getMinutes().toString().padStart(2,'0')}${now.getSeconds().toString().padStart(2,'0')}`;
link.download = `题ID_序列名_批量_${timestamp}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => {
URL.revokeObjectURL(objectUrl);
btn.disabled = false;
isTopicDownloading = false;
}, 300);
addLog(`📥 已导出 ${topicCollectorData.reduce((sum, t) => sum + t.data.length, 0)} 条数据,来自 ${topicCollectorData.length} 个任务`, 'success');
});
// ================= API 数据导出 =================
function apiLog(msg, type = 'info') {
const el = document.getElementById('api-log');
if (!el) return;
const colors = { info: '#cccccc', success: '#4EC9B0', error: '#F56C6C', warn: '#E6A23C' };
el.innerHTML += `${new Date().toLocaleTimeString()} - ${msg}
`;
el.scrollTop = el.scrollHeight;
}
// 采集任务列表(从当前页面Vue表格读取)
document.getElementById('api-collect').addEventListener('click', function() {
const tableData = getVueTableData();
if (!tableData || tableData.length === 0) {
apiLog('⚠️ 未从页面获取到任务数据,请确认在当前项目列表页', 'warn');
return;
}
const container = document.getElementById('api-task-list');
if (!container) return;
container.innerHTML = '';
tableData.forEach(item => {
const name = item.task_name || '';
const key = item.task_key || '';
if (!key) return;
const div = document.createElement('div');
div.style.marginBottom = '6px';
div.innerHTML = `
`;
container.appendChild(div);
});
apiLog('✅ 采集到 ' + tableData.length + ' 个任务(已自动全选)', 'success');
});
// 全选/清空
document.getElementById('api-select-all').addEventListener('click', () => {
document.querySelectorAll('.api-task-cb').forEach(cb => cb.checked = true);
});
document.getElementById('api-unselect-all').addEventListener('click', () => {
document.querySelectorAll('.api-task-cb').forEach(cb => cb.checked = false);
});
document.getElementById('api-test').addEventListener('click', async function() {
const ak = document.getElementById('api-ak').value.trim();
if (!ak) { apiLog('请先填写 Access Key', 'error'); return; }
const base = location.origin;
apiLog('正在测试连接...', 'info');
try {
const r = await fetch(base + '/v2/users/info', {
method: 'POST',
headers: { 'access-key': ak, 'Content-Type': 'application/json;charset=UTF-8' },
body: '{}'
});
const t = await r.text();
if (t[0] === '<') { apiLog('❌ 返回了 HTML 页面(未登录或 access-key 错误)', 'error'); return; }
const j = JSON.parse(t);
if (j.code && j.code !== '') {
apiLog('❌ ' + (j.msg || j.code), 'error');
} else {
apiLog('✅ ' + j.data.nickname + ' (uid=' + j.data.user_id + ')', 'success');
}
} catch(e) {
apiLog('❌ 网络错误: ' + e.message, 'error');
}
});
document.getElementById('api-start').addEventListener('click', async function() {
const btn = this;
btn.disabled = true;
btn.textContent = '⏳ 执行中...';
const base = location.origin;
const ak = document.getElementById('api-ak').value.trim();
const checked = document.querySelectorAll('.api-task-cb:checked');
const keys = Array.from(checked).map(cb => cb.dataset.key).filter(Boolean);
if (!ak) { apiLog('请填写 Access Key', 'error'); btn.disabled = false; btn.textContent = '▶ 开始导出'; return; }
if (keys.length === 0) { apiLog('请先勾选要导出的任务(点击「采集任务列表」获取)', 'error'); btn.disabled = false; btn.textContent = '▶ 开始导出'; return; }
const bar = document.getElementById('api-bar');
const progText = document.getElementById('api-progress');
const statusEl = document.getElementById('api-status');
function h(ex) { return { 'access-key': ak, 'Accept': 'application/json, */*', 'Content-Type': 'application/json;charset=UTF-8', ...ex } }
async function api(url, opt) {
const r = await fetch(url, { ...opt, headers: { ...h(), ...(opt ? opt.headers : {}) } });
const t = await r.text();
if (t[0] === '<') throw new Error('服务器返回了 HTML 页面(可能未登录或 access-key 错误)');
const j = JSON.parse(t);
if (j.code && j.code !== '') throw new Error(j.msg || j.code);
return j;
}
let successCount = 0, failCount = 0;
for (let ki = 0; ki < keys.length; ki++) {
const TK = keys[ki];
const overallPct = (ki / keys.length) * 100;
bar.style.width = overallPct + '%';
progText.textContent = `进度: ${ki}/${keys.length}`;
statusEl.textContent = `处理中: ${TK}`;
apiLog(`========== [${ki + 1}/${keys.length}] ${TK} ==========`, 'info');
try {
// 1. 登录验证
apiLog('[1/5] 验证登录...', 'info');
await api(base + '/v2/users/info', { method: 'POST', body: '{}' });
apiLog(' ✓ 登录成功', 'success');
// 2. 统计
apiLog('[2/5] 获取任务统计...', 'info');
const c = await api(base + '/v2/reports/' + TK + '/check-info');
const s = c.data;
apiLog(' ✓ 预期包数: ' + s.package_total_num + ' | 标注: ' + s.label_num + ' | 质检: ' + s.check_num, 'success');
// 3. 获取包列表
apiLog('[3/5] 获取包列表...', 'info');
let all = [], pg = 1, tot = 0;
while (true) {
const d = await api(base + '/v2/packages?task_batch_key=' + TK + '&work_type=4&page=' + pg + '&page_num=20&get_result_package=1');
const items = d.data.items || [];
tot = d.data.meta ? d.data.meta.total_num || 0 : 0;
all = all.concat(items);
if (items.length < 20 || all.length >= tot) break;
pg++;
}
apiLog(' ✓ ' + all.length + ' 个包', 'success');
// 获取推送时间
let pushTime = '';
if (all.length > 0) {
try {
const dsMatch = all[0].config_url.match(/\/default\/([^/]+)\//);
const dsId = dsMatch ? dsMatch[1] : '';
if (dsId) {
const pt = await api(base + '/v2/datasets/' + dsId + '/push-tasks?expand=user&dataset_id=' + dsId + '&page_num=50&page=1');
const pushes = (pt.data.items || []).filter(t => t.task_key === TK);
if (pushes.length > 0) {
const d = new Date(pushes[0].created_at * 1000);
pushTime = d.getDate() + '/' + (d.getMonth() + 1) + '/' + d.getFullYear() +
' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0');
}
}
} catch(e) {
apiLog(' ⚠️ 推送时间获取失败: ' + e.message.substring(0, 50), 'warn');
}
}
// 4. 逐包获取 tasks
apiLog('[4/5] 逐包获取 tasks(含真实 task_id)...', 'info');
const recs = [];
for (let i = 0; i < all.length; i++) {
const p = all[i];
try {
const d = await api(base + '/v2/task-batches/' + TK + '/packages/' + p.package_id + '/tasks?task_id=&file_name=&seg_dir_name=&is_issues=&work_type=1&mark_status=');
const items = d.data.items || [];
const a = p.label_info ? p.label_info.user_id : null;
const q = p.check_info ? p.check_info.user_id : null;
for (const t of items) {
const ts = parseFloat(t.file_name.replace('.pcd', ''));
recs.push({ task_id: t.task_id, a, q, file_name: t.file_name, _t: ts });
}
} catch(e) {
apiLog(' ⚠️ 包 ' + p.package_id + ' 失败: ' + e.message.substring(0, 50), 'warn');
}
// 更新子进度(50~85)
const subPct = 50 + Math.round((i + 1) / all.length * 35);
bar.style.width = (overallPct + (subPct / keys.length)) + '%';
}
apiLog(' ✓ 累计 ' + recs.length + ' 条文件记录', 'success');
if (recs.length === 0) {
apiLog('❌ 没有获取到任何文件记录', 'error');
failCount++;
continue;
}
// 5. 整理导出
apiLog('[5/5] 整理数据并导出...', 'info');
recs.sort((a, b) => (a.a || 0) - (b.a || 0) || a._t - b._t);
const out = recs.map(r => ({
task_id: r.task_id,
annotator: r.a,
annotation_qa: r.q,
file_name: r.file_name,
annotated_info: pushTime
}));
// 分组统计
const grp = {};
for (const r of out) {
const k = r.annotator + '-' + r.annotation_qa;
grp[k] = (grp[k] || 0) + 1;
}
let detail = '';
for (const [k, v] of Object.entries(grp)) {
detail += ' annotator=' + k.split('-')[0] + ' qa=' + k.split('-')[1] + ' → ' + v + '条;';
}
// 下载
const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = TK + '_api_export.json'; a.click();
URL.revokeObjectURL(url);
successCount++;
apiLog('✅ ' + TK + ' 导出完成: ' + out.length + ' 条 | ' + Object.keys(grp).length + ' 组 | ' + detail, 'success');
} catch(e) {
failCount++;
apiLog('❌ ' + TK + ' 失败: ' + e.message, 'error');
}
}
bar.style.width = '100%';
progText.textContent = `进度: ${keys.length}/${keys.length}`;
statusEl.textContent = successCount > 0 ? '✅ 全部完成' : '❌ 全部失败';
statusEl.style.color = successCount > 0 ? '#67C23A' : '#F56C6C';
btn.disabled = false;
btn.textContent = '▶ 开始导出';
apiLog(`🎉 全部完成: 成功 ${successCount}, 失败 ${failCount}`, successCount > 0 ? 'success' : 'warn');
});
// ================= 数据看板 - 刷新数据 =================
const viewRefreshBtn = document.getElementById('view-refresh');
if (viewRefreshBtn) {
viewRefreshBtn.addEventListener('click', function() {
renderViewList();
addLog('数据看板已刷新', 'info');
});
}
// 数据看板 - 全选
const viewSelectAllBtn = document.getElementById('view-select-all');
if (viewSelectAllBtn) {
viewSelectAllBtn.addEventListener('click', function() {
viewSelectedTasks.clear();
collectAllData.forEach((item, idx) => {
viewSelectedTasks.add(item.task_key || idx);
});
renderViewList();
});
}
// 数据看板 - 清空
const viewUnselectAllBtn = document.getElementById('view-unselect-all');
if (viewUnselectAllBtn) {
viewUnselectAllBtn.addEventListener('click', function() {
viewSelectedTasks.clear();
renderViewList();
});
}
// ================= 10. 数据看板功能 =================
let viewSelectedTasks = new Set();
function safeNum(val) {
const n = parseFloat(val);
return isNaN(n) ? 0 : n;
}
function renderViewList() {
const listEl = document.getElementById('view-list');
if (!listEl) return;
if (!collectAllData || collectAllData.length === 0) {
listEl.innerHTML = '请先在“全量采集”标签页采集数据
';
updateViewStats();
return;
}
listEl.innerHTML = '';
collectAllData.forEach((item, idx) => {
const isSelected = viewSelectedTasks.has(item.task_key || idx);
const row = document.createElement('div');
row.style.cssText = 'display: grid; grid-template-columns: 30px 1fr 60px 60px 60px 60px 60px; gap: 4px; padding: 6px 4px; border-bottom: 1px dashed #ebeef5; align-items: center; font-size: 12px;';
row.innerHTML = `
${item.task_name || '-'}
${safeNum(item.total_num)}
${safeNum(item.label_progress)}
${safeNum(item.check_progress)}
${safeNum(item.inspect_progress)}
${safeNum(item.accept_progress)}
`;
listEl.appendChild(row);
});
// 绑定复选框事件
listEl.querySelectorAll('.view-task-check').forEach(cb => {
cb.addEventListener('change', function() {
const key = this.dataset.key;
if (this.checked) {
viewSelectedTasks.add(key);
} else {
viewSelectedTasks.delete(key);
}
updateViewStats();
});
});
updateViewStats();
}
function updateViewStats() {
const totalNumEl = document.getElementById('view-total-num');
const labelProgressEl = document.getElementById('view-label-progress');
const checkProgressEl = document.getElementById('view-check-progress');
const inspectProgressEl = document.getElementById('view-inspect-progress');
const acceptProgressEl = document.getElementById('view-accept-progress');
if (!totalNumEl) return;
let totalNum = 0;
let totalLabel = 0;
let totalCheck = 0;
let totalInspect = 0;
let totalAccept = 0;
collectAllData.forEach((item, idx) => {
const key = item.task_key || idx;
if (viewSelectedTasks.size === 0 || viewSelectedTasks.has(key)) {
totalNum += safeNum(item.total_num);
totalLabel += safeNum(item.label_progress);
totalCheck += safeNum(item.check_progress);
totalInspect += safeNum(item.inspect_progress);
totalAccept += safeNum(item.accept_progress);
}
});
totalNumEl.textContent = totalNum;
labelProgressEl.textContent = totalLabel;
checkProgressEl.textContent = totalCheck;
inspectProgressEl.textContent = totalInspect;
acceptProgressEl.textContent = totalAccept;
}
// ================= 11. 初始化 =================
if (projects.length === 0) {
projects.push({ id: 1, name: '默认项目', tasks: [], nextTaskId: 1 });
currentProjectId = 1;
saveToStorage();
}
renderProjectSelect();
updateProgress();
refreshTopicTaskCheckboxList();
window.addEventListener('beforeunload', saveToStorage);
addLog('控制台已启动,新增“数据看板”标签页', 'success');
})();