Files
xianyu-hunter-hermes-skill/project/xianyu-hunter/public/app.js
T
chick efd6eb8fb9 Fix xianyu QR login refresh
Exclude tiny QR-login icon candidates, expose captured QR dimensions in the Web UI, and refresh the portable Hermes skill release archives.
2026-05-07 15:29:05 +08:00

1095 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ========== 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 = `<span class="log-time">${entry.time}</span>${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 = `
<div>Cookie 数:${data.cookieCount ?? 0}</div>
<div>账号:${escapeHtml(data.accountName || (ok ? '已登录' : '未识别'))}</div>
<div>登录墙:${data.loginWall ? '是' : '否'} / 登录字样:${data.loginTextVisible ? '可见' : '不可见'}</div>
<div>商品卡片:${data.cardCount ?? 0}</div>
<div>浏览器:${data.browserOpen ? '已打开' : '未打开'},页面:${escapeHtml(data.title || '')}</div>
<div>当前 URL${escapeHtml(data.url || '')}</div>
<div>保存目录:${escapeHtml(data.storage || 'browser-data/')}</div>
`;
}
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)
? '(已从闲鱼入口跳转到淘宝/阿里统一登录,这是官方登录链路)'
: '';
const sizeHint = data.qrImageWidth && data.qrImageHeight ? ` 截图尺寸:${data.qrImageWidth}×${data.qrImageHeight}` : '';
sub.textContent = `来源:${source}${redirected}。二维码过期就重新获取。${sizeHint}`;
}
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 = '<div class="cache-preview-empty">没有可清理的任务:请选择一个非运行中的任务,或切换到“全部任务”。</div>';
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 `<div class="cache-preview-row">
<div><strong>${escapeHtml(t.name || t.id)}</strong><div class="subtle">${hasAnalysis ? `Hermes 推荐 ${t.hermesAnalysis.recommendedCount || 0} / 谨慎 ${t.hermesAnalysis.cautionCount || 0}` : '尚无 Hermes 分析,保留优质结果模式会保留 0 件'}</div></div>
<div class="cache-counts">采集 ${raw}|粗筛 ${coarse}|细筛 ${fine}|将保留 <b>${keep}</b></div>
</div>`;
}).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 => `
<div class="task-card ${t.id === activeTaskId ? 'active' : ''}" onclick="selectTask('${t.id}')">
<div class="task-card-name">${escapeHtml(t.name)}</div>
<div class="task-card-meta">
<span class="stage-badge stage-${t.stage}">${STAGE_LABELS[t.stage] || t.stage}</span>
<span style="font-size:11px;color:var(--text2)">
${t.config?.schedule?.enabled ? '⏰ ' : ''}${t.products ? `${t.products.rawCount || 0}` : ''}
</span>
</div>
</div>
`).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]) => `
<button class="overview-card overview-card-clickable" type="button" onclick="openHomeCard('${target}')" title="${action}">
<div class="overview-icon">${icon}</div>
<div><div class="overview-value">${value}</div><div class="overview-label">${label}</div><div class="overview-sub">${sub}</div><div class="overview-action">${action} →</div></div>
</button>
`).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 = `
<div class="home-empty-mini">
<div>还没有任务</div>
<small>可以先创建一个关键词任务,例如 CPU、Mac mini、硬盘、显卡等。</small>
<button class="btn btn-sm btn-primary" onclick="showCreateModal()">立即创建</button>
</div>
`;
return;
}
list.innerHTML = recent.map(t => `
<button class="home-task-item" onclick="selectTask('${t.id}')">
<div>
<strong>${escapeHtml(t.name || t.id)}</strong>
<small>${escapeHtml((t.config?.queries || []).slice(0, 3).join(' / ') || '未设置关键词')}</small>
</div>
<div class="home-task-side">
<span class="stage-badge stage-${t.stage}">${STAGE_LABELS[t.stage] || t.stage}</span>
<small>${t.hermesAnalysis ? `推荐 ${t.hermesAnalysis.recommendedCount || 0}` : `候选 ${t.products?.fineCount || 0}`}</small>
<small>${formatTimeShort(t.updatedAt || t.createdAt)}</small>
</div>
</button>
`).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 ? '<span class="pipeline-arrow">→</span>' : ''}
<div class="pipeline-step ${cls}">${STAGE_LABELS[stage]}</div>
`;
}).join('');
html += `
<div class="pipeline-controls">
<button class="btn btn-sm btn-home" onclick="showHome()" title="返回首页仪表盘">← 首页</button>
<button class="btn btn-sm" onclick="editTask('${task.id}')" title="编辑任务配置">编辑</button>
<button class="btn btn-sm" onclick="duplicateTask('${task.id}')" title="复制任务配置">复制</button>
${task.running
? `<button class="btn btn-danger btn-sm" onclick="stopTask('${task.id}')">停止任务</button>`
: `<button class="btn btn-sm" style="color:var(--green)" onclick="startTaskNow('${task.id}')">继续/启动</button>
<button class="btn btn-sm" style="color:var(--orange)" onclick="rerunTask('${task.id}')">重新采集</button>
<button class="btn btn-sm" style="color:var(--accent2)" onclick="analyzeTaskNow('${task.id}')">执行分析</button>
<button class="btn btn-sm" onclick="deleteTask('${task.id}')">删除</button>`
}
</div>
`;
container.innerHTML = html;
}
function renderProductStats() {
const task = tasks.find(t => t.id === activeTaskId);
if (!task?.products) return;
document.getElementById('product-stats').innerHTML = `
<div class="stat-card"><div class="stat-value">${task.products.rawCount || 0}</div><div class="stat-label">采集总量</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--orange)">${task.products.coarseCount || 0}</div><div class="stat-label">粗筛通过</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--green)">${task.products.fineCount || 0}</div><div class="stat-label">细筛通过</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--accent)">${task.hermesAnalysis ? task.hermesAnalysis.recommendedCount : (task.products.fineCount || 0)}</div><div class="stat-label">${task.hermesAnalysis ? '推荐候选' : '待 Hermes 分析'}</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--blue)">${task.config?.maxItems || 60}</div><div class="stat-label">单次上限</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--orange)">${task.config?.schedule?.enabled ? `${task.config.schedule.intervalMinutes}m` : '关'}</div><div class="stat-label">定时</div></div>
`;
}
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 `<tr>
<td>${p.image ? `<img class="product-img" src="${p.image}" loading="lazy">` : '-'}</td>
<td class="product-title" title="${escapeHtml(p.title)}">${p.href ? `<a href="${escapeHtml(p.href)}" target="_blank">${escapeHtml(p.title?.slice(0, 60) || '')}</a>` : escapeHtml(p.title?.slice(0, 60) || '')}${analysis?.reason ? `<div class="product-reason">${escapeHtml(analysis.reason)}</div>` : ''}</td>
<td class="product-price">${escapeHtml(p.detailPrice || p.price || '')}</td>
<td>${escapeHtml(p.sellerLocation || '')}</td>
<td><span class="badge ${badgeCls}">${badge}</span></td>
</tr>`;
}).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 = `
<div class="analysis-empty">
<div class="empty-icon">🦐</div>
<div>还没有 Hermes 分析结果</div>
<div class="subtle">细筛通过后,点击上方“执行分析”会生成推荐/谨慎/排除结果,并按通知设置推送。</div>
<button class="btn btn-primary" style="margin-top:14px" onclick="analyzeTaskNow('${task.id}')" ${task.products?.fineCount ? '' : 'disabled'}>执行分析并通知</button>
</div>
`;
return;
}
const label = { recommend: '推荐', caution: '谨慎', reject: '排除' };
const cls = { recommend: 'recommend', caution: 'caution', reject: 'reject' };
const rows = (summary.topItems || []).map(item => `
<div class="analysis-item ${cls[item.verdict] || ''}">
<div class="analysis-item-head">
<span class="analysis-score">${item.score}</span>
<span class="badge ${item.verdict === 'recommend' ? 'badge-fine' : item.verdict === 'reject' ? 'badge-reject' : 'badge-coarse'}">${label[item.verdict] || item.verdict}</span>
<span class="analysis-title">${item.href ? `<a href="${escapeHtml(item.href)}" target="_blank">${escapeHtml(item.title || '(无标题)')}</a>` : escapeHtml(item.title || '(无标题)')}</span>
</div>
<div class="analysis-meta">价格:${escapeHtml(item.price || '-')}|地区:${escapeHtml(item.location || '-')}</div>
<div class="analysis-reason">${escapeHtml(item.reason || '')}</div>
${item.flags?.length ? `<div class="analysis-flags">风险:${item.flags.map(escapeHtml).join('')}</div>` : ''}
</div>
`).join('');
panel.innerHTML = `
<div class="analysis-summary">
<div class="stat-card"><div class="stat-value" style="color:var(--green)">${summary.recommendedCount || 0}</div><div class="stat-label">推荐</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--orange)">${summary.cautionCount || 0}</div><div class="stat-label">谨慎</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--red)">${summary.rejectedCount || 0}</div><div class="stat-label">排除</div></div>
<div class="stat-card"><div class="stat-value">${summary.total || 0}</div><div class="stat-label">候选总数</div></div>
<div class="analysis-actions"><button class="btn btn-primary" onclick="analyzeTaskNow('${task.id}')">重新分析并通知</button>${summary.reportPath ? `<span class="hint">报告:${escapeHtml(summary.reportPath)}</span>` : ''}</div>
</div>
<div class="analysis-list">${rows || '<div class="subtle">暂无分析条目</div>'}</div>
`;
}
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 =>
`<div class="log-entry"><span class="log-time">${l.time}</span>${escapeHtml(l.message)}</div>`
).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;
document.getElementById('n-max-items').value = notifyConfig.maxNotifyItems ?? 10;
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 => `<option value="${escapeHtml(o.value)}" ${o.value === normalized ? 'selected' : ''}>${escapeHtml(o.label || o.value)}</option>`).join('');
return `${opts}${known ? '' : `<option value="${escapeHtml(normalized)}" selected>${escapeHtml(normalized)}</option>`}`;
};
box.innerHTML = channels.map((c, i) => {
const isHermes = (c.type || 'hermes-send-message') === 'hermes-send-message' || c.type === 'hermes-webhook';
return `
<div class="notify-channel" data-index="${i}">
<div class="form-row">
<div class="form-group checkbox-group"><label><input class="nc-enabled" type="checkbox" ${c.enabled ? 'checked' : ''}> 启用</label></div>
<div class="form-group"><label>类型</label><select class="nc-type" onchange="renderNotifyChannelsFromDom()"><option value="hermes-send-message" ${isHermes?'selected':''}>Hermes 平台消息</option><option value="bark" ${c.type==='bark'?'selected':''}>Bark</option><option value="pushplus" ${c.type==='pushplus'?'selected':''}>PushPlus</option><option value="webhook" ${c.type==='webhook'?'selected':''}>自定义Webhook</option></select></div>
<div class="form-group"><label>名称</label><input class="nc-name" value="${escapeHtml(c.name || '')}" placeholder="飞书/微信/QQ/Bark"></div>
<button class="btn btn-sm btn-danger" type="button" onclick="removeNotifyChannel(${i})">删除</button>
</div>
${isHermes ? `
<div class="form-row">
<div class="form-group"><label>发送到</label><select class="nc-target">${renderTargetOptions(c.target)}</select></div>
<div class="form-group"><label>自定义目标 <span class="hint">可选,格式如 feishu:chat_id / weixin:openid / qqbot</span></label><input class="nc-custom-target" value="${targetOptions.some(o => o.value === c.target) ? '' : escapeHtml(c.target || '')}" placeholder="留空则使用左侧下拉"></div>
</div>
<input class="nc-url" type="hidden" value="${escapeHtml(c.url || '')}"><input class="nc-token" type="hidden" value="${escapeHtml(c.token || '')}">
` : `
<div class="form-group"><label>URL <span class="hint">Bark 填服务器地址或完整推送地址;PushPlus 可留默认;自定义 Webhook 发 JSON</span></label><input class="nc-url" value="${escapeHtml(c.url || '')}" placeholder="https://..."></div>
<div class="form-row">
<div class="form-group"><label>Token/Key</label><input class="nc-token" value="${escapeHtml(c.token || '')}" placeholder="Bark key / PushPlus token"></div>
<input class="nc-target" type="hidden" value="${escapeHtml(c.target || '')}">
</div>
`}
</div>`;
}).join('') || '<div class="subtle">暂无通道,点击下方按钮添加。</div>';
}
function renderNotifyChannelsFromDom() {
notifyConfig = collectNotifyConfig();
renderNotifyChannels();
}
function addNotifyChannel(type) {
if (!notifyConfig) notifyConfig = { enabled: true, notifyOnlyGood: true, minScore: 75, maxNotifyItems: 10, 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),
maxNotifyItems: Number(document.getElementById('n-max-items').value || 10),
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ========== Init ==========
connectWS();
loadDefaults();