// ========== State ========== let ws = null; let tasks = []; let activeTaskId = null; let defaultCoarsePrompt = ''; let defaultFinePrompt = ''; let editingTaskId = null; let notifyConfig = null; const THEME_STORAGE_KEY = 'xianyu-hunter-theme'; // ========== Theme ========== function getPreferredTheme() { const saved = localStorage.getItem(THEME_STORAGE_KEY); if (saved === 'light' || saved === 'dark') return saved; return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; } function applyTheme(theme) { const normalized = theme === 'light' ? 'light' : 'dark'; document.documentElement.dataset.theme = normalized; const icon = document.getElementById('theme-icon'); const label = document.getElementById('theme-label'); if (icon) icon.textContent = normalized === 'light' ? '☀️' : '🌙'; if (label) label.textContent = normalized === 'light' ? '浅色' : '深色'; } function toggleTheme() { const current = document.documentElement.dataset.theme || getPreferredTheme(); const next = current === 'light' ? 'dark' : 'light'; localStorage.setItem(THEME_STORAGE_KEY, next); applyTheme(next); } applyTheme(getPreferredTheme()); const STAGE_LABELS = { pending: '等待中', login: '登录验证', searching: '搜索采集', coarse_filter: '粗筛', fine_filter: '细筛', completed: '已完成', stopped: '已停止', stopping: '停止中', }; const STAGES_ORDER = ['login', 'searching', 'coarse_filter', 'fine_filter']; // ========== WebSocket ========== function connectWS() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${protocol}//${location.host}`); ws.onopen = () => { document.getElementById('ws-status').classList.add('connected'); }; ws.onclose = () => { document.getElementById('ws-status').classList.remove('connected'); setTimeout(connectWS, 2000); }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); handleWSMessage(msg); }; } function handleWSMessage(msg) { const { event, taskId, data } = msg; switch (event) { case 'connected': if (data?.tasks) { tasks = data.tasks; renderTaskList(); renderHomeDashboard(); } break; case 'task:created': tasks.push(data); renderTaskList(); renderHomeDashboard(); selectTask(data.id); break; case 'task:updated': { const idx = tasks.findIndex(t => t.id === taskId); if (idx >= 0) tasks[idx] = data; else tasks.push(data); renderTaskList(); renderHomeDashboard(); if (activeTaskId === taskId) renderTaskDetail(); } break; case 'task:started': case 'task:stopping': updateTaskField(taskId, 'running', event === 'task:started'); if (event === 'task:stopping') updateTaskField(taskId, 'stage', 'stopping'); renderTaskList(); if (activeTaskId === taskId) renderPipeline(); break; case 'task:stage': updateTaskField(taskId, 'stage', data.stage); renderTaskList(); if (activeTaskId === taskId) renderPipeline(); break; case 'task:log': appendLog(taskId, data); break; case 'task:products': updateProductCounts(taskId, data); if (activeTaskId === taskId) renderProductStats(); break; case 'task:finished': const idx = tasks.findIndex(t => t.id === taskId); if (idx >= 0) tasks[idx] = data; renderTaskList(); renderHomeDashboard(); if (activeTaskId === taskId) renderTaskDetail(); break; } } function updateTaskField(taskId, field, value) { const task = tasks.find(t => t.id === taskId); if (task) task[field] = value; } function updateProductCounts(taskId, data) { const task = tasks.find(t => t.id === taskId); if (!task) return; if (!task.products) task.products = {}; task.products.rawCount = data.rawCount; task.products.coarseCount = data.coarseCount; task.products.fineCount = data.fineCount; } function appendLog(taskId, entry) { const task = tasks.find(t => t.id === taskId); if (!task) return; if (!task.logs) task.logs = []; task.logs.push(entry); if (task.logs.length > 500) task.logs = task.logs.slice(-300); if (activeTaskId === taskId) { const panel = document.getElementById('log-panel'); const div = document.createElement('div'); div.className = 'log-entry'; div.innerHTML = `${entry.time}${escapeHtml(entry.message)}`; panel.appendChild(div); panel.scrollTop = panel.scrollHeight; } } // ========== API ========== async function apiPut(url, body) { const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok && data.error) { alert(data.error); throw new Error(data.error); } return data; } async function apiPost(url, body) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok && data.error) { alert(data.error); throw new Error(data.error); } return data; } function showLoginModal() { document.getElementById('login-modal-overlay').classList.add('show'); checkLoginStatus(); } function hideLoginModal() { document.getElementById('login-modal-overlay').classList.remove('show'); } function renderLoginStatus(data) { const card = document.getElementById('login-status-card'); const meta = document.getElementById('login-status-meta'); const accountRow = document.getElementById('login-account-row'); const accountName = document.getElementById('login-account-name'); const accountSub = document.getElementById('login-account-sub'); const avatar = document.getElementById('login-avatar'); const ok = !!data.loggedIn; card.classList.toggle('ok', ok); card.classList.toggle('bad', !ok); card.querySelector('.login-status-title').textContent = ok ? '✅ 已登录,Cookie 可复用' : '⚠️ 未登录或仍在登录墙'; accountRow.style.display = ok ? 'flex' : 'none'; accountName.textContent = data.accountName || '已登录账号'; accountSub.textContent = `Cookie ${data.cookieCount ?? 0} · ${data.checkedAt ? new Date(data.checkedAt).toLocaleString() : '刚刚检测'}`; if (data.avatarUrl) { avatar.src = data.avatarUrl; avatar.style.display = 'block'; } else { avatar.removeAttribute('src'); avatar.style.display = 'none'; } meta.innerHTML = `
Cookie 数:${data.cookieCount ?? 0}
账号:${escapeHtml(data.accountName || (ok ? '已登录' : '未识别'))}
登录墙:${data.loginWall ? '是' : '否'} / 登录字样:${data.loginTextVisible ? '可见' : '不可见'}
商品卡片:${data.cardCount ?? 0}
浏览器:${data.browserOpen ? '已打开' : '未打开'},页面:${escapeHtml(data.title || '')}
当前 URL:${escapeHtml(data.url || '')}
保存目录:${escapeHtml(data.storage || 'browser-data/')}
`; } function renderLoginQr(data) { const panel = document.getElementById('login-qr-panel'); const img = document.getElementById('login-qr-img'); const title = document.getElementById('login-qr-title'); const sub = document.getElementById('login-qr-sub'); if (title) title.textContent = data.qrScreenshotKind || '扫码登录'; if (sub) { const source = data.qrSource || '闲鱼 Web 登录入口'; const redirected = data.qrUrl && /login\.taobao\.com|havanaone/.test(data.qrUrl) ? '(已从闲鱼入口跳转到淘宝/阿里统一登录,这是官方登录链路)' : ''; sub.textContent = `来源:${source}${redirected}。二维码过期就重新获取。`; } if (data.qrImage) { img.src = data.qrImage; panel.style.display = 'block'; } else if (data.loggedIn) { clearLoginQr(); } } function clearLoginQr() { const panel = document.getElementById('login-qr-panel'); const img = document.getElementById('login-qr-img'); img.removeAttribute('src'); panel.style.display = 'none'; } function appendLoginLog(lines) { const box = document.getElementById('login-log'); const text = Array.isArray(lines) ? lines.join('\n') : String(lines || ''); if (text) box.textContent = `${new Date().toLocaleTimeString()}\n${text}\n\n${box.textContent}`; } async function checkLoginStatus() { try { const res = await fetch('/api/login/status'); const data = await res.json(); if (!res.ok) throw new Error(data.error || '检测失败'); renderLoginStatus(data); appendLoginLog(`检测完成:${data.loggedIn ? '已登录' : '未登录'},cookie=${data.cookieCount ?? 0}`); return data; } catch (err) { appendLoginLog(`检测登录状态失败:${err.message}`); throw err; } } async function openLoginWindow() { appendLoginLog('正在打开 Mac 本机 Chromium 闲鱼登录窗口...'); try { const result = await apiPost('/api/login/open', {}); renderLoginStatus(result); appendLoginLog(result.logs || '已请求打开登录窗口'); } catch (err) { appendLoginLog(`打开登录窗口失败:${err.message}`); } } async function fetchLoginQr() { appendLoginLog('正在从闲鱼 Web 登录入口获取二维码/登录面板截图...'); try { const result = await apiPost('/api/login/qr', {}); renderLoginStatus(result); renderLoginQr(result); appendLoginLog(result.logs || '二维码获取完成'); } catch (err) { appendLoginLog(`获取登录二维码失败:${err.message}`); } } async function waitLoginStatus() { appendLoginLog('开始等待登录完成,期间请在 Chromium 中扫码/验证码登录...'); try { const result = await apiPost('/api/login/wait', {}); renderLoginStatus(result); appendLoginLog(result.logs || '等待检测完成'); } catch (err) { appendLoginLog(`等待检测失败:${err.message}`); } } async function closeLoginBrowser() { if (!confirm('确认关闭当前登录会话浏览器?Cookie 已写入 browser-data 后通常不会丢。')) return; try { await apiPost('/api/login/close-browser', {}); appendLoginLog('已关闭登录会话浏览器'); await checkLoginStatus(); } catch (err) { appendLoginLog(`关闭登录会话失败:${err.message}`); } } async function importCookieFile(file) { if (!file) return; appendLoginLog(`正在读取 Cookie JSON:${file.name}`); try { const text = await file.text(); let payload; try { payload = JSON.parse(text); } catch { throw new Error('JSON 解析失败,请确认文件是浏览器扩展导出的 Cookie JSON'); } const result = await apiPost('/api/login/import-cookies', payload); renderLoginStatus(result); appendLoginLog([...(result.logs || []), `导入完成:${result.imported || 0} 条;状态:${result.loggedIn ? '已登录' : '仍需重新登录/验证'}`]); } catch (err) { appendLoginLog(`导入 Cookie 失败:${err.message}`); } } function collectTaskForm() { const name = document.getElementById('f-name').value.trim(); const queriesRaw = document.getElementById('f-queries').value.trim(); const priceMin = document.getElementById('f-price-min').value; const priceMax = document.getElementById('f-price-max').value; const maxPages = document.getElementById('f-pages').value; const maxItems = document.getElementById('f-max-items').value; const region = document.getElementById('f-region').value.trim(); const personalSeller = document.getElementById('f-personal').checked; const customRequirements = document.getElementById('f-requirements').value.trim(); const coarsePrompt = document.getElementById('f-coarse-prompt').value.trim(); const finePrompt = document.getElementById('f-fine-prompt').value.trim(); const scheduleEnabled = document.getElementById('f-schedule-enabled').checked; const scheduleIntervalMinutes = document.getElementById('f-schedule-interval').value; if (!queriesRaw) throw new Error('请输入至少一个搜索关键词'); const queries = queriesRaw.split(/[,,]/).map(s => s.trim()).filter(Boolean); return { name: name || queries[0], queries, priceMin: priceMin ? Number(priceMin) : null, priceMax: priceMax ? Number(priceMax) : null, maxPages: maxPages ? Number(maxPages) : 3, maxItems: maxItems ? Number(maxItems) : 60, region, personalSeller, autoAnalyze: document.getElementById('f-auto-analyze')?.checked !== false, autoNotify: document.getElementById('f-auto-notify')?.checked !== false, customRequirements, coarsePrompt: coarsePrompt || '', finePrompt: finePrompt || '', scheduleEnabled, scheduleIntervalMinutes: scheduleIntervalMinutes ? Number(scheduleIntervalMinutes) : 0, }; } async function saveTask(autoStart) { let payload; try { payload = collectTaskForm(); } catch (err) { alert(err.message); return; } try { let task; if (editingTaskId) { task = await apiPut(`/api/tasks/${editingTaskId}`, payload); if (autoStart) await apiPost(`/api/tasks/${editingTaskId}/start`, { reset: true }); } else { task = await apiPost('/api/tasks', { ...payload, autoStart }); } hideCreateModal(); clearForm(); editingTaskId = null; if (task?.id) selectTask(task.id); } catch { /* alert already shown */ } } async function createTask() { return saveTask(true); } async function optimizeTaskForm() { const status = document.getElementById('optimize-status'); status.textContent = '优化中...'; try { const result = await apiPost('/api/tasks/optimize', { product: document.getElementById('f-queries').value.trim() || document.getElementById('f-name').value.trim(), budget: document.getElementById('f-price-max').value.trim(), region: document.getElementById('f-region').value.trim(), currentRequirements: document.getElementById('f-requirements').value.trim(), }); fillTaskForm(result.task || {}); status.textContent = result.source === 'hermes' ? '已由 Hermes Agent 优化' : '已用本地规则优化(Hermes 不可用时兜底)'; } catch (err) { status.textContent = `优化失败:${err.message}`; } } function fillTaskForm(taskOrConfig, name = '') { const c = taskOrConfig.config || taskOrConfig; document.getElementById('f-name').value = taskOrConfig.name || name || ''; document.getElementById('f-queries').value = (c.queries || []).join(', '); document.getElementById('f-price-min').value = c.priceMin ?? ''; document.getElementById('f-price-max').value = c.priceMax ?? ''; document.getElementById('f-pages').value = c.maxPages || 3; document.getElementById('f-max-items').value = c.maxItems || 60; document.getElementById('f-region').value = c.region || ''; document.getElementById('f-personal').checked = !!c.personalSeller; const autoAnalyze = document.getElementById('f-auto-analyze'); if (autoAnalyze) autoAnalyze.checked = c.autoAnalyze !== false; const autoNotify = document.getElementById('f-auto-notify'); if (autoNotify) autoNotify.checked = c.autoNotify !== false; document.getElementById('f-requirements').value = c.customRequirements || ''; document.getElementById('f-coarse-prompt').value = c.coarsePrompt || defaultCoarsePrompt; document.getElementById('f-fine-prompt').value = c.finePrompt || defaultFinePrompt; document.getElementById('f-schedule-enabled').checked = !!c.schedule?.enabled; document.getElementById('f-schedule-interval').value = c.schedule?.intervalMinutes || 0; } async function stopTask(id) { await apiPost(`/api/tasks/${id}/stop`); } async function startTaskNow(id) { await apiPost(`/api/tasks/${id}/start`, {}); } async function rerunTask(id) { if (!confirm('重新采集会清空本任务本轮旧结果,确认继续?')) return; await apiPost(`/api/tasks/${id}/start`, { reset: true }); } async function analyzeTaskNow(id) { const task = tasks.find(t => t.id === id); if (!task?.products?.fineCount) { alert('当前没有待分析候选'); return; } try { const result = await apiPost(`/api/tasks/${id}/analyze`, { notify: true }); alert(`分析完成:推荐 ${result.analysis.recommendedCount} / 谨慎 ${result.analysis.cautionCount} / 排除 ${result.analysis.rejectedCount}${result.notification?.skipped ? '\n通知:未达到通知条件' : ''}`); await refreshTask(id); } catch (err) { alert(`分析失败:${err.message}`); } } function getClearTargets() { const scope = document.getElementById('cache-task-scope')?.value || 'active'; if (scope === 'all') return tasks.filter(t => !t.running); const active = tasks.find(t => t.id === activeTaskId); return active && !active.running ? [active] : []; } function countQualityItems(task, mode, minScore) { const fine = task?.products?.fineFiltered || []; return fine.filter(item => { const a = item.hermesAnalysis; if (!a) return false; if (mode === 'hermes-recommend') return a.verdict === 'recommend'; if (mode === 'hermes-pass') return (a.verdict === 'recommend' || a.verdict === 'caution') && Number(a.score || 0) >= Number(minScore || 0); return false; }).length; } function showClearCacheModal() { document.getElementById('clear-cache-modal-overlay').classList.add('show'); const scope = document.getElementById('cache-task-scope'); if (scope && !activeTaskId) scope.value = 'all'; document.getElementById('cache-log').textContent = ''; renderClearCachePreview(); } function hideClearCacheModal() { document.getElementById('clear-cache-modal-overlay').classList.remove('show'); } function renderClearCachePreview() { const mode = document.getElementById('cache-clear-mode')?.value || 'all'; const minScore = Number(document.getElementById('cache-min-score')?.value || 75); const minRow = document.getElementById('cache-min-score-row'); if (minRow) minRow.style.display = mode === 'hermes-pass' ? 'block' : 'none'; const targets = getClearTargets(); const preview = document.getElementById('cache-preview'); if (!preview) return; if (!targets.length) { preview.innerHTML = '
没有可清理的任务:请选择一个非运行中的任务,或切换到“全部任务”。
'; return; } const rows = targets.map(t => { const raw = t.products?.rawCount ?? t.products?.raw?.length ?? 0; const coarse = t.products?.coarseCount ?? t.products?.coarseFiltered?.length ?? 0; const fine = t.products?.fineCount ?? t.products?.fineFiltered?.length ?? 0; const keep = mode === 'all' ? 0 : countQualityItems(t, mode, minScore); const hasAnalysis = !!t.hermesAnalysis; return `
${escapeHtml(t.name || t.id)}
${hasAnalysis ? `Hermes 推荐 ${t.hermesAnalysis.recommendedCount || 0} / 谨慎 ${t.hermesAnalysis.cautionCount || 0}` : '尚无 Hermes 分析,保留优质结果模式会保留 0 件'}
采集 ${raw}|粗筛 ${coarse}|细筛 ${fine}|将保留 ${keep}
`; }).join(''); preview.innerHTML = rows; } async function clearCacheFromModal() { const mode = document.getElementById('cache-clear-mode').value; const minScore = Number(document.getElementById('cache-min-score').value || 75); const targets = getClearTargets(); if (!targets.length) { alert('没有可清理的任务'); return; } const label = mode === 'all' ? '全部清除' : mode === 'hermes-recommend' ? '只保留 Hermes 推荐结果' : `保留 Hermes 通过且 ≥${minScore} 分的优质结果`; if (!confirm(`确认对 ${targets.length} 个任务执行「${label}」?\n\n此操作会改写任务缓存,但不会删除任务配置和登录 Cookie。`)) return; const log = document.getElementById('cache-log'); log.textContent = `开始清理 ${targets.length} 个任务...\n`; const results = []; for (const task of targets) { try { const result = await apiPost(`/api/tasks/${task.id}/clear-cache`, { mode, minScore }); results.push(result); const idx = tasks.findIndex(t => t.id === task.id); if (idx >= 0 && result.task) tasks[idx] = result.task; log.textContent += `✅ ${task.name}: ${result.before.raw}/${result.before.coarseFiltered}/${result.before.fineFiltered} → ${result.after.raw}/${result.after.coarseFiltered}/${result.after.fineFiltered}\n`; } catch (err) { log.textContent += `❌ ${task.name}: ${err.message}\n`; } } renderTaskList(); if (activeTaskId) await refreshTask(activeTaskId); renderClearCachePreview(); const kept = results.reduce((sum, r) => sum + (r.kept || 0), 0); log.textContent += `完成:保留 ${kept} 个优质结果。\n`; } function duplicateTask(id) { const task = tasks.find(t => t.id === id); if (!task?.config) return; editingTaskId = null; fillTaskForm(task, task.name ? `${task.name} (副本)` : ''); document.getElementById('task-modal-title').textContent = '复制为新任务'; document.getElementById('save-run-btn').textContent = '创建并采集'; document.getElementById('save-only-btn').textContent = '仅保存任务'; showCreateModal(); } function editTask(id) { const task = tasks.find(t => t.id === id); if (!task?.config) return; if (task.running) { alert('任务运行中,请先停止后再编辑'); return; } editingTaskId = id; fillTaskForm(task, task.name || ''); document.getElementById('task-modal-title').textContent = '编辑采集任务'; document.getElementById('save-run-btn').textContent = '保存并重新采集'; document.getElementById('save-only-btn').textContent = '仅保存修改'; showCreateModal(); } async function deleteTask(id) { if (!confirm('确认删除此任务?')) return; await fetch(`/api/tasks/${id}`, { method: 'DELETE' }); tasks = tasks.filter(t => t.id !== id); if (activeTaskId === id) { activeTaskId = null; renderTaskDetail(); } renderTaskList(); } async function refreshTask(id) { const res = await fetch(`/api/tasks/${id}`); const task = await res.json(); const idx = tasks.findIndex(t => t.id === id); if (idx >= 0) tasks[idx] = task; if (activeTaskId === id) renderTaskDetail(); } // ========== Render: Task List ========== function renderTaskList() { const container = document.getElementById('task-list'); container.innerHTML = tasks.map(t => `
${escapeHtml(t.name)}
${STAGE_LABELS[t.stage] || t.stage} ${t.config?.schedule?.enabled ? '⏰ ' : ''}${t.products ? `${t.products.rawCount || 0}件` : ''}
`).join(''); } function showHome() { activeTaskId = null; renderTaskList(); const detail = document.getElementById('task-detail'); const empty = document.getElementById('empty-state'); if (detail) detail.style.display = 'none'; if (empty) { empty.style.display = 'block'; empty.scrollTop = 0; } renderHomeDashboard(); } function selectTask(id, tab = 'products') { activeTaskId = id; renderTaskList(); refreshTask(id).then(() => { renderTaskDetail(); switchTab(tab); }); } function findLatestTask(predicate = () => true) { return [...tasks] .filter(predicate) .sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0))[0]; } function openHomeCard(target) { const withProducts = t => Number(t.products?.rawCount || 0) > 0; const withFine = t => Number(t.products?.fineCount || 0) > 0; const withRecommend = t => Number(t.hermesAnalysis?.recommendedCount || 0) > 0; const handlers = { tasks: () => findLatestTask(), raw: () => findLatestTask(withProducts), fine: () => findLatestTask(withFine), recommend: () => findLatestTask(withRecommend), running: () => findLatestTask(t => t.running), scheduled: () => findLatestTask(t => t.config?.schedule?.enabled), }; const task = (handlers[target] || handlers.tasks)(); if (!task) { showCreateModal(); return; } const tab = target === 'recommend' ? 'analysis' : 'products'; selectTask(task.id, tab); } function scrollHomeRecentTasks() { document.getElementById('home-task-list')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } function formatTimeShort(ts) { if (!ts) return '-'; const d = new Date(ts); if (Number.isNaN(d.getTime())) return '-'; return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); } function renderHomeDashboard() { const overview = document.getElementById('home-overview'); const list = document.getElementById('home-task-list'); if (!overview || !list) return; const totals = tasks.reduce((acc, t) => { acc.tasks += 1; acc.running += t.running ? 1 : 0; acc.raw += Number(t.products?.rawCount || 0); acc.fine += Number(t.products?.fineCount || 0); acc.recommend += Number(t.hermesAnalysis?.recommendedCount || 0); acc.scheduled += t.config?.schedule?.enabled ? 1 : 0; return acc; }, { tasks: 0, running: 0, raw: 0, fine: 0, recommend: 0, scheduled: 0 }); const cards = [ ['任务总数', totals.tasks, '已保存的监测配置', '📋', 'tasks', '打开最近任务'], ['采集缓存', totals.raw, '当前保留商品', '📦', 'raw', '查看商品列表'], ['细筛候选', totals.fine, '可进入 Hermes 分析', '🎯', 'fine', '查看候选商品'], ['推荐结果', totals.recommend, 'Hermes 判定优质', '🦐', 'recommend', '查看分析结果'], ['运行中', totals.running, '正在采集任务', '⚡', 'running', '查看运行任务'], ['定时任务', totals.scheduled, '后台自动运行', '⏰', 'scheduled', '查看定时任务'], ]; overview.innerHTML = cards.map(([label, value, sub, icon, target, action]) => ` `).join(''); const recent = [...tasks].sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0)).slice(0, 6); if (!recent.length) { list.innerHTML = `
还没有任务
可以先创建一个关键词任务,例如 CPU、Mac mini、硬盘、显卡等。
`; return; } list.innerHTML = recent.map(t => ` `).join(''); } // ========== Render: Task Detail ========== function renderTaskDetail() { const task = tasks.find(t => t.id === activeTaskId); const detail = document.getElementById('task-detail'); const empty = document.getElementById('empty-state'); if (!task) { detail.style.display = 'none'; empty.style.display = 'block'; renderHomeDashboard(); return; } detail.style.display = 'flex'; empty.style.display = 'none'; renderPipeline(); renderProductStats(); renderProducts(); renderAnalysis(); renderLogs(); } function renderPipeline() { const task = tasks.find(t => t.id === activeTaskId); if (!task) return; const currentIdx = STAGES_ORDER.indexOf(task.stage); const container = document.getElementById('pipeline'); let html = STAGES_ORDER.map((stage, i) => { let cls = ''; if (task.stage === 'completed') { cls = 'done'; } else if (task.stage === 'stopped' || task.stage === 'stopping') { cls = i <= currentIdx ? 'done' : ''; } else if (i < currentIdx) { cls = 'done'; } else if (i === currentIdx) { cls = 'active'; } return ` ${i > 0 ? '' : ''}
${STAGE_LABELS[stage]}
`; }).join(''); html += `
${task.running ? `` : ` ` }
`; container.innerHTML = html; } function renderProductStats() { const task = tasks.find(t => t.id === activeTaskId); if (!task?.products) return; document.getElementById('product-stats').innerHTML = `
${task.products.rawCount || 0}
采集总量
${task.products.coarseCount || 0}
粗筛通过
${task.products.fineCount || 0}
细筛通过
${task.hermesAnalysis ? task.hermesAnalysis.recommendedCount : (task.products.fineCount || 0)}
${task.hermesAnalysis ? '推荐候选' : '待 Hermes 分析'}
${task.config?.maxItems || 60}
单次上限
${task.config?.schedule?.enabled ? `${task.config.schedule.intervalMinutes}m` : '关'}
定时
`; } function renderProducts() { const task = tasks.find(t => t.id === activeTaskId); if (!task?.products) return; const analyzedFine = new Map((task.products.fineFiltered || []).map(p => [p.id, p.hermesAnalysis])); const fineIds = new Set((task.products.fineFiltered || []).map(p => p.id)); const coarseIds = new Set((task.products.coarseFiltered || []).map(p => p.id)); const all = task.products.raw || []; const tbody = document.getElementById('product-tbody'); tbody.innerHTML = all.map(p => { let badge, badgeCls; const analysis = p.hermesAnalysis || analyzedFine.get(p.id); if (analysis) { badge = analysis.verdict === 'recommend' ? `推荐 ${analysis.score}` : analysis.verdict === 'reject' ? `排除 ${analysis.score}` : `谨慎 ${analysis.score}`; badgeCls = analysis.verdict === 'recommend' ? 'badge-fine' : analysis.verdict === 'reject' ? 'badge-reject' : 'badge-coarse'; } else if (fineIds.has(p.id)) { badge = '待分析'; badgeCls = 'badge-fine'; } else if (coarseIds.has(p.id)) { badge = '粗筛✓'; badgeCls = 'badge-coarse'; } else { badge = '采集'; badgeCls = 'badge-raw'; } return ` ${p.image ? `` : '-'} ${p.href ? `${escapeHtml(p.title?.slice(0, 60) || '')}` : escapeHtml(p.title?.slice(0, 60) || '')}${analysis?.reason ? `
${escapeHtml(analysis.reason)}
` : ''} ${escapeHtml(p.detailPrice || p.price || '')} ${escapeHtml(p.sellerLocation || '')} ${badge} `; }).join(''); } function renderAnalysis() { const task = tasks.find(t => t.id === activeTaskId); const panel = document.getElementById('analysis-panel'); if (!panel) return; const summary = task?.hermesAnalysis; if (!task) return; if (!summary) { panel.innerHTML = `
🦐
还没有 Hermes 分析结果
细筛通过后,点击上方“执行分析”会生成推荐/谨慎/排除结果,并按通知设置推送。
`; return; } const label = { recommend: '推荐', caution: '谨慎', reject: '排除' }; const cls = { recommend: 'recommend', caution: 'caution', reject: 'reject' }; const rows = (summary.topItems || []).map(item => `
${item.score} ${label[item.verdict] || item.verdict} ${item.href ? `${escapeHtml(item.title || '(无标题)')}` : escapeHtml(item.title || '(无标题)')}
价格:${escapeHtml(item.price || '-')}|地区:${escapeHtml(item.location || '-')}
${escapeHtml(item.reason || '')}
${item.flags?.length ? `
风险:${item.flags.map(escapeHtml).join(';')}
` : ''}
`).join(''); panel.innerHTML = `
${summary.recommendedCount || 0}
推荐
${summary.cautionCount || 0}
谨慎
${summary.rejectedCount || 0}
排除
${summary.total || 0}
候选总数
${summary.reportPath ? `报告:${escapeHtml(summary.reportPath)}` : ''}
${rows || '
暂无分析条目
'}
`; } function renderLogs() { const task = tasks.find(t => t.id === activeTaskId); const panel = document.getElementById('log-panel'); const logs = task?.logs || []; panel.innerHTML = logs.map(l => `
${l.time}${escapeHtml(l.message)}
` ).join(''); panel.scrollTop = panel.scrollHeight; } // ========== Tab Switching ========== function switchTab(tab) { document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === `tab-${tab}`)); } // ========== Notification Settings ========== async function showNotifyModal() { document.getElementById('notify-modal-overlay').classList.add('show'); await loadNotifyConfig(); } function hideNotifyModal() { document.getElementById('notify-modal-overlay').classList.remove('show'); } async function loadNotifyConfig() { const res = await fetch('/api/notify-config'); notifyConfig = await res.json(); document.getElementById('n-enabled').checked = notifyConfig.enabled !== false; document.getElementById('n-good-only').checked = notifyConfig.notifyOnlyGood !== false; document.getElementById('n-min-score').value = notifyConfig.minScore ?? 75; renderNotifyChannels(); } function renderNotifyChannels() { const box = document.getElementById('notify-channels'); const channels = notifyConfig?.channels || []; const targetOptions = notifyConfig?.targetOptions || [ { value: 'feishu', label: '飞书 Home' }, { value: 'weixin', label: '微信 Home' }, { value: 'qqbot', label: 'QQ 机器人 Home(如已配置)' }, ]; const renderTargetOptions = (target = '') => { const normalized = target || 'feishu'; const known = targetOptions.some(o => o.value === normalized); const opts = targetOptions.map(o => ``).join(''); return `${opts}${known ? '' : ``}`; }; box.innerHTML = channels.map((c, i) => { const isHermes = (c.type || 'hermes-send-message') === 'hermes-send-message' || c.type === 'hermes-webhook'; return `
${isHermes ? `
` : `
`}
`; }).join('') || '
暂无通道,点击下方按钮添加。
'; } function renderNotifyChannelsFromDom() { notifyConfig = collectNotifyConfig(); renderNotifyChannels(); } function addNotifyChannel(type) { if (!notifyConfig) notifyConfig = { enabled: true, notifyOnlyGood: true, minScore: 75, channels: [] }; const normalizedType = type === 'hermes-webhook' ? 'hermes-send-message' : type; const defaults = { 'hermes-send-message': { name: 'Hermes 平台消息', target: 'feishu:oc_3e963a150d98f254d8f38881bcfa1812', url: '' }, bark: { name: 'Bark', url: 'https://api.day.app', token: '' }, pushplus: { name: 'PushPlus', url: 'https://www.pushplus.plus/send', token: '' }, webhook: { name: '自定义 Webhook', url: '' }, }[normalizedType] || {}; notifyConfig.channels.push({ id: `${normalizedType}-${Date.now()}`, type: normalizedType, enabled: true, ...defaults }); renderNotifyChannels(); } function removeNotifyChannel(i) { notifyConfig.channels.splice(i, 1); renderNotifyChannels(); } function collectNotifyConfig() { const channels = [...document.querySelectorAll('.notify-channel')].map(el => { const selectedTarget = el.querySelector('.nc-target')?.value?.trim() || ''; const customTarget = el.querySelector('.nc-custom-target')?.value?.trim() || ''; return { enabled: el.querySelector('.nc-enabled').checked, type: el.querySelector('.nc-type').value, name: el.querySelector('.nc-name').value.trim(), url: el.querySelector('.nc-url')?.value?.trim() || '', token: el.querySelector('.nc-token')?.value?.trim() || '', target: customTarget || selectedTarget, id: notifyConfig?.channels?.[Number(el.dataset.index)]?.id, }; }); return { enabled: document.getElementById('n-enabled').checked, notifyOnlyGood: document.getElementById('n-good-only').checked, minScore: Number(document.getElementById('n-min-score').value || 75), channels, }; } async function saveNotifyConfigFromForm() { const log = document.getElementById('notify-log'); try { notifyConfig = await apiPut('/api/notify-config', collectNotifyConfig()); log.textContent = `保存成功:${new Date().toLocaleTimeString()}\n${log.textContent || ''}`; renderNotifyChannels(); } catch (err) { log.textContent = `保存失败:${err.message}\n${log.textContent || ''}`; } } async function testNotifyConfigFromForm() { const log = document.getElementById('notify-log'); const payload = collectNotifyConfig(); const enabled = (payload.channels || []).filter(c => c.enabled); if (!enabled.length) { log.textContent = `测试失败:请至少启用一个通知渠道\n${log.textContent || ''}`; return; } log.textContent = `正在发送测试通知到 ${enabled.map(c => c.name || c.target || c.type).join('、')}...\n${log.textContent || ''}`; try { const result = await apiPost('/api/notify-test', { config: payload, title: 'xianyu-hunter Web 测试通知' }); const detail = (result.results || []).map(r => `${r.ok ? '✅' : '❌'} ${r.name || r.id}${r.error ? `:${r.error}` : ''}`).join('\n') || (result.ok ? '✅ 已发送' : '❌ 发送失败'); log.textContent = `测试完成:${new Date().toLocaleTimeString()}\n${detail}\n${log.textContent || ''}`; } catch (err) { log.textContent = `测试失败:${err.message}\n${log.textContent || ''}`; } } // ========== Modal ========== function showCreateModal() { if (!editingTaskId && !document.getElementById('f-queries').value) { document.getElementById('task-modal-title').textContent = '新建采集任务'; document.getElementById('save-run-btn').textContent = '保存并采集'; document.getElementById('save-only-btn').textContent = '仅保存任务'; } document.getElementById('modal-overlay').classList.add('show'); } function hideCreateModal() { document.getElementById('modal-overlay').classList.remove('show'); } function clearForm() { ['f-name', 'f-queries', 'f-price-min', 'f-price-max', 'f-region', 'f-requirements'].forEach(id => { document.getElementById(id).value = ''; }); document.getElementById('f-pages').value = '3'; document.getElementById('f-max-items').value = '60'; document.getElementById('f-schedule-enabled').checked = false; document.getElementById('f-schedule-interval').value = '0'; document.getElementById('f-personal').checked = false; document.getElementById('task-modal-title').textContent = '新建采集任务'; document.getElementById('save-run-btn').textContent = '保存并采集'; document.getElementById('save-only-btn').textContent = '仅保存任务'; const opt = document.getElementById('optimize-status'); if (opt) opt.textContent = ''; document.getElementById('f-coarse-prompt').value = defaultCoarsePrompt; document.getElementById('f-fine-prompt').value = defaultFinePrompt; } function resetCoarsePrompt() { document.getElementById('f-coarse-prompt').value = defaultCoarsePrompt; } function resetFinePrompt() { document.getElementById('f-fine-prompt').value = defaultFinePrompt; } async function loadDefaults() { try { const res = await fetch('/api/defaults'); const data = await res.json(); defaultCoarsePrompt = data.coarsePrompt || ''; defaultFinePrompt = data.finePrompt || ''; document.getElementById('f-coarse-prompt').value = defaultCoarsePrompt; document.getElementById('f-fine-prompt').value = defaultFinePrompt; } catch { /* ignore */ } } // ========== Utils ========== function escapeHtml(str) { if (!str) return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ========== Init ========== connectWS(); loadDefaults();