// ========== 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 => `
价格:${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)}` : ''}
`;
}
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();