'use strict'; // 局域网访问口令:服务端开启 accessToken 时,API 返回 401 会引导输入口令, // 写入 cookie 后所有请求(fetch / 图片 / CSV 链接)自动携带,无需逐处改代码。 // 并发 401 共享同一次弹窗;用户取消只结束本轮,后续 401 仍可再次询问。 (function () { const origFetch = window.fetch.bind(window); let tokenPromise = null; window.fetch = async function (input, init) { const res = await origFetch(input, init); const url = typeof input === 'string' ? input : (input && input.url) || String(input || ''); if (res.status === 401 && /\/(api|files)\b/.test(url)) { if (!tokenPromise) { tokenPromise = (async () => { const token = window.prompt('此服务已开启访问口令(accessToken),请输入:'); if (token) { document.cookie = 'accessToken=' + encodeURIComponent(token) + '; path=/; max-age=31536000; SameSite=Lax'; } tokenPromise = null; // 本轮结束;取消后下次 401 仍会询问 return token || ''; })(); } const token = await tokenPromise; if (token) return origFetch(input, init); } return res; }; })(); (function () { const views = { crawl: document.getElementById('viewCrawl'), gen: document.getElementById('viewGen'), tasks: document.getElementById('viewTasks'), history: document.getElementById('viewHistory'), reports: document.getElementById('viewReports'), lake: document.getElementById('viewLake'), analysis: document.getElementById('viewAnalysis'), ask: document.getElementById('viewAsk'), settings: document.getElementById('viewSettings'), logs: document.getElementById('viewLogs'), }; const navItems = document.querySelectorAll('.nav-item[data-view]'); let settingsBound = false; let settingsLoaded = false; let logTimer = null; let lastLogSignature = ''; async function loadModels(selectedModel) { const select = document.getElementById('settingsModel'); const response = await fetch('/api/models'); if (!response.ok) throw new Error('模型列表加载失败'); const data = await response.json(); select.innerHTML = ''; for (const model of data.models || []) { const option = document.createElement('option'); option.value = typeof model === 'string' ? model : model.id; option.textContent = typeof model === 'string' ? model : (model.label || model.name || model.id); option.selected = option.value === selectedModel; select.appendChild(option); } } async function loadSettings() { const response = await fetch('/api/settings'); if (!response.ok) throw new Error('设置加载失败'); const settings = await response.json(); document.getElementById('settingsApiKey').value = settings.apiKey || ''; document.getElementById('settingsApiBase').value = settings.apiBase || ''; document.getElementById('settingsRootDir').value = settings.rootDir || ''; document.getElementById('pageSizeInput').value = settings.pageSize || 50; document.getElementById('delayInput').value = settings.delayMs ?? 300; const cookieInput = document.getElementById('settingsCookie'); cookieInput.value = ''; cookieInput.placeholder = settings.cookieConfigured ? '已配置,输入新 Cookie 可更换' : '登录 melody.shop.ele.me 后从 DevTools 复制'; document.getElementById('settingsChatModel').value = settings.chatModel || 'deepseek-v4.1-flash'; document.getElementById('settingsChatApiBase').value = settings.chatApiBase || ''; document.getElementById('settingsChatModelAgent').value = settings.chatModelAgent || ''; document.getElementById('settingsChatJsonMode').checked = !!settings.chatJsonMode; const chatKeyInput = document.getElementById('settingsChatApiKey'); chatKeyInput.value = settings.chatApiKey || ''; chatKeyInput.placeholder = settings.chatKeyFallback ? '留空复用上方 API Key' : (settings.chatApiKey ? '已配置,输入新 Key 可更换' : '留空复用上方 API Key;否则填对话模型专用 Key'); const tokenInput = document.getElementById('settingsAccessToken'); tokenInput.value = ''; tokenInput.placeholder = settings.accessTokenSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置'; document.getElementById('settingsAitableEnabled').checked = !!settings.aitableEnabled; document.getElementById('settingsAitableAppKey').value = settings.aitableAppKey || ''; document.getElementById('settingsAitableBaseId').value = settings.aitableBaseId || ''; document.getElementById('settingsAitableSheetDetail').value = settings.aitableSheetDetail || '门店明细'; document.getElementById('settingsAitableSheetChain').value = settings.aitableSheetChain || '连锁汇总'; document.getElementById('settingsAitableSheetCompany').value = settings.aitableSheetCompany || '公司汇总'; const aitableSecretInput = document.getElementById('settingsAitableAppSecret'); aitableSecretInput.value = ''; aitableSecretInput.placeholder = settings.aitableSecretSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置'; await loadModels(settings.model); } function setPanelStatus(badgeId, message, ok) { const badge = document.getElementById(badgeId); if (!badge) return; badge.textContent = message; badge.className = 'badge ' + (ok === true ? 'badge-ok' : ok === false ? 'badge-fail' : 'badge-info'); } async function saveSettingsPart(fields, buttonId, badgeId, successText = '已保存') { const button = document.getElementById(buttonId); button.disabled = true; setPanelStatus(badgeId, '保存中…'); try { const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields()), }); const data = await response.json(); if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); await loadSettings(); setPanelStatus(badgeId, successText, true); } catch (error) { setPanelStatus(badgeId, error.message, false); } finally { button.disabled = false; } } const val = (id) => document.getElementById(id).value.trim(); const hasNewSecret = (id) => { const v = val(id); return v && !v.includes('••••'); }; function saveApiSettings() { return saveSettingsPart(() => ({ apiKey: val('settingsApiKey'), model: document.getElementById('settingsModel').value, chatModel: document.getElementById('settingsChatModel').value, chatModelAgent: document.getElementById('settingsChatModelAgent').value, chatJsonMode: document.getElementById('settingsChatJsonMode').checked, ...(hasNewSecret('settingsChatApiKey') ? { chatApiKey: val('settingsChatApiKey') } : {}), }), 'btnSaveApi', 'apiSaveStatus'); } function saveCrawlSettings() { return saveSettingsPart(() => ({ rootDir: val('settingsRootDir'), pageSize: Number(document.getElementById('pageSizeInput').value), delayMs: Number(document.getElementById('delayInput').value), ...(val('settingsCookie') ? { cookie: val('settingsCookie') } : {}), }), 'btnSaveCrawl', 'crawlSaveStatus'); } function saveSecuritySettings() { return saveSettingsPart(() => ({ ...(val('settingsAccessToken') ? { accessToken: val('settingsAccessToken') } : {}), }), 'btnSaveSecurity', 'securitySaveStatus'); } function saveAitableSettings() { return saveSettingsPart(() => ({ aitableEnabled: document.getElementById('settingsAitableEnabled').checked, aitableAppKey: val('settingsAitableAppKey'), aitableBaseId: val('settingsAitableBaseId'), aitableSheetDetail: val('settingsAitableSheetDetail'), aitableSheetChain: val('settingsAitableSheetChain'), aitableSheetCompany: val('settingsAitableSheetCompany'), ...(val('settingsAitableAppSecret') ? { aitableAppSecret: val('settingsAitableAppSecret') } : {}), }), 'btnSaveAitable', 'aitableSaveStatus'); } async function testImageApi() { const status = document.getElementById('imageTestStatus'); const button = document.getElementById('btnTestImage'); button.disabled = true; status.textContent = '测试中…'; status.className = 'badge badge-info'; try { const response = await fetch('/api/image/test', { method: 'POST' }); const data = await response.json(); if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); if (data.statusCode === 0) throw new Error(data.error || '网络不可达'); const model = data.model || ''; if (data.statusCode === 200 && data.modelFound) { status.textContent = '连通正常,模型在列表中'; status.className = 'badge badge-ok'; } else if (data.statusCode === 200 && Array.isArray(data.models)) { status.textContent = `服务可达(${data.models.length} 个模型);列表中未找到 ${model}`; status.className = 'badge badge-warn'; } else if (data.statusCode === 200) { status.textContent = '服务可达;该网关不提供模型列表,无法校验模型名'; status.className = 'badge badge-warn'; } else if (data.statusCode === 401 || data.statusCode === 403) { status.textContent = `服务返回 ${data.statusCode}:Key 可能无效`; status.className = 'badge badge-fail'; } else { status.textContent = `服务返回 ${data.statusCode}`; status.className = 'badge badge-fail'; } } catch (error) { status.textContent = '失败:' + error.message; status.className = 'badge badge-fail'; } finally { button.disabled = false; } } async function testChatModel() { const status = document.getElementById('chatTestStatus'); const button = document.getElementById('btnTestChat'); button.disabled = true; status.textContent = '测试中…'; status.className = 'badge badge-info'; try { const response = await fetch('/api/chat/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chatModel: document.getElementById('settingsChatModel').value.trim(), chatApiKey: document.getElementById('settingsChatApiKey').value.trim(), }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); status.textContent = `连通正常:${data.reply || 'OK'}`; status.className = 'badge badge-ok'; } catch (error) { status.textContent = '失败:' + error.message; status.className = 'badge badge-fail'; } finally { button.disabled = false; } } function bindSettingsTabs() { const tabs = document.getElementById('settingsApiTabs'); if (!tabs) return; tabs.addEventListener('click', (event) => { const btn = event.target.closest('button[data-tab]'); if (!btn || btn.classList.contains('active')) return; tabs.querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn)); document.getElementById('settingsTabImage').hidden = btn.dataset.tab !== 'image'; document.getElementById('settingsTabChat').hidden = btn.dataset.tab !== 'chat'; }); } // 云端动作(测试/推/拉);badgeId 允许不同页面各自展示状态(设置页/数据采集页/数据中台页) async function aitableAction(kind, badgeId = 'aitableStatus', body = {}) { const status = document.getElementById(badgeId); const labels = { test: '测试连通中…', push: '推送中…', pull: '拉取中…' }; if (status) { status.textContent = labels[kind]; status.className = 'badge badge-info'; } try { const res = await fetch(`/api/reports/aitable/${kind}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); if (kind === 'test') { // 逐表探测:缺哪张表报哪张(testConnection 返回 { ok, sheets: { detail/chain/company: {name, ok, hint} } }) const failed = Object.values(data.sheets || {}).filter((s) => !s.ok); if (status) { if (data.ok) { status.textContent = '三张表连通正常'; status.className = 'badge badge-ok'; } else { status.textContent = `不可读:${failed.map((s) => s.name).join('、')}`; status.className = 'badge badge-fail'; } } return data; } if (kind === 'push') { if (data.pushed === 0) { if (status) { status.textContent = '本地与云端一致,无需推送'; status.className = 'badge badge-ok'; } } else { const stale = data.skippedStale ? `,跳过云端较新 ${data.skippedStale} 行` : ''; if (status) { status.textContent = `已推送 ${data.pushed} 行(新增 ${data.created} / 更新 ${data.updated}${stale})`; status.className = 'badge badge-ok'; } } } else { const detail = (data.accounts || []).map((a) => `${a.account} ${a.rows}行`).join('、') || '无数据'; if (status) { status.textContent = `已拉取 ${data.pulled} 行:${detail}`; status.className = 'badge badge-ok'; } } return data; } catch (error) { if (status) { status.textContent = error.message; status.className = 'badge badge-fail'; } throw error; } } window.shanAitableAction = aitableAction; async function initSettings() { // 事件绑定与数据加载分离:加载失败重进设置页时不会重复 addEventListener if (!settingsBound) { settingsBound = true; bindSettingsTabs(); if (new URLSearchParams(location.search).get('tab') === 'chat') { document.querySelector('#settingsApiTabs button[data-tab="chat"]')?.click(); } document.getElementById('btnSaveApi').addEventListener('click', saveApiSettings); document.getElementById('btnSaveCrawl').addEventListener('click', saveCrawlSettings); document.getElementById('btnSaveSecurity').addEventListener('click', saveSecuritySettings); document.getElementById('btnSaveAitable').addEventListener('click', saveAitableSettings); document.getElementById('btnTestImage').addEventListener('click', testImageApi); document.getElementById('btnTestChat').addEventListener('click', testChatModel); document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test')); const bindHelpPopover = (btnId, popId) => { const btn = document.getElementById(btnId); const pop = document.getElementById(popId); btn.addEventListener('click', (event) => { event.stopPropagation(); pop.hidden = !pop.hidden; btn.classList.toggle('active', !pop.hidden); btn.setAttribute('aria-expanded', String(!pop.hidden)); }); document.addEventListener('click', (event) => { if (!pop.hidden && !event.target.closest('.conn-help-wrap')) { pop.hidden = true; btn.classList.remove('active'); btn.setAttribute('aria-expanded', 'false'); } }); }; bindHelpPopover('btnSecurityHelp', 'securityHelpPopover'); bindHelpPopover('btnAitableHelp', 'aitableHelpPopover'); document.getElementById('btnClearLog').addEventListener('click', () => { clearLogs().catch(() => {}); }); } if (!settingsLoaded) { try { await loadSettings(); settingsLoaded = true; } catch (error) { setPanelStatus('apiSaveStatus', error.message, false); } } } function escapeLogText(value) { const div = document.createElement('div'); div.textContent = String(value ?? ''); return div.innerHTML; } function renderLogs(logs) { const panel = document.getElementById('logPanel'); if (!panel) return; const sourceMap = { task: '任务', system: '系统' }; const levelMap = { info: 'INFO', success: 'OK', warn: 'WARN', error: 'FAIL' }; panel.innerHTML = logs.map((log) => { const level = levelMap[log.level] || 'INFO'; const levelClass = log.level === 'success' ? 'ok' : log.level || 'info'; return `
${escapeLogText(log.time)} ${sourceMap[log.source] || log.source} ${level} ${escapeLogText(log.message)}
`; }).join(''); } async function refreshLogs() { const response = await fetch('/api/logs'); if (!response.ok) return; const data = await response.json(); const logs = (data.logs || []).sort((a, b) => (b.seq || 0) - (a.seq || 0)); const signature = logs.map((log) => `${log.seq}:${log.level}:${log.message}`).join('|'); if (signature === lastLogSignature) return; lastLogSignature = signature; renderLogs(logs); } function startLogPolling() { if (logTimer) return; refreshLogs(); logTimer = setInterval(refreshLogs, 1500); } function stopLogPolling() { if (logTimer) { clearInterval(logTimer); logTimer = null; } } async function clearLogs() { await fetch('/api/logs/clear', { method: 'POST' }); lastLogSignature = ''; await refreshLogs(); } function switchView(name) { for (const [key, el] of Object.entries(views)) { el.classList.toggle('active', key === name); } navItems.forEach((n) => n.classList.toggle('active', n.dataset.view === name)); if (name === 'crawl' && window.shanCrawlInit) window.shanCrawlInit(); if (window.shanCrawlSetActive) window.shanCrawlSetActive(name === 'crawl'); // 离开采集页暂停状态轮询 if (name === 'gen' && window.shanGenInit) window.shanGenInit(); if (name === 'tasks' && window.shanTasksInit) window.shanTasksInit(); if (window.shanTasksSetActive) window.shanTasksSetActive(name === 'tasks'); // 离开任务页清掉轮询 if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit(); if (name === 'reports' && window.shanReportsInit) window.shanReportsInit(); if (name === 'lake' && window.shanLakeInit) window.shanLakeInit(); // 明细 tab 激活时再懒加载报表 if (name === 'analysis' && window.shanAnalysisActivate) window.shanAnalysisActivate(); if (name === 'ask' && window.shanAskInit) window.shanAskInit(); if (name === 'settings') initSettings(); // 系统日志:仅日志页激活时轮询 if (name === 'logs') startLogPolling(); else stopLogPolling(); } navItems.forEach((n) => n.addEventListener('click', () => switchView(n.dataset.view))); const requestedView = new URLSearchParams(location.search).get('view'); switchView(views[requestedView] ? requestedView : 'crawl'); const splash = document.getElementById('splash'); if (splash) { setTimeout(() => splash.classList.add('hide'), 1800); setTimeout(() => splash.remove(), 2400); } })();