efd6eb8fb9
Exclude tiny QR-login icon candidates, expose captured QR dimensions in the Web UI, and refresh the portable Hermes skill release archives.
1095 lines
46 KiB
JavaScript
1095 lines
46 KiB
JavaScript
// ========== 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
// ========== Init ==========
|
||
connectWS();
|
||
loadDefaults();
|