| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397 |
- 'use strict';
- // 局域网访问口令:服务端开启 accessToken 时,API 返回 401 会引导输入一次口令,
- // 写入 cookie 后所有请求(fetch / 图片 / CSV 链接)自动携带,无需逐处改代码。
- (function () {
- let tokenAsked = false;
- const origFetch = window.fetch.bind(window);
- 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) && !tokenAsked) {
- tokenAsked = true;
- const token = window.prompt('此服务已开启访问口令(accessToken),请输入:');
- if (token) {
- document.cookie = 'accessToken=' + encodeURIComponent(token) + '; path=/; max-age=31536000; SameSite=Lax';
- tokenAsked = false;
- 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'),
- settings: document.getElementById('viewSettings'),
- logs: document.getElementById('viewLogs'),
- };
- const navItems = document.querySelectorAll('.nav-item[data-view]');
- 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 || '';
- 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,
- ...(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') {
- 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() {
- if (settingsLoaded) return;
- 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(() => {});
- });
- 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 `
- <div class="log-line">
- <span class="log-ts">${escapeLogText(log.time)}</span>
- <span class="log-source ${log.source === 'system' ? 'system' : 'task'}">${sourceMap[log.source] || log.source}</span>
- <span class="log-lvl ${levelClass}">${level}</span>
- <span class="log-msg">${escapeLogText(log.message)}</span>
- </div>`;
- }).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 - a.seq);
- 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 (name === 'gen' && window.shanGenInit) window.shanGenInit();
- if (name === 'tasks' && window.shanTasksInit) window.shanTasksInit();
- 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 === 'settings') initSettings();
- // 系统日志:仅日志页激活时轮询
- if (name === 'logs') startLogPolling();
- else stopLogPolling();
- // 智能问数机器人:数据采集(问数入口语义在报表浏览,浏览已挪中台但采集页仍是分析组入口)、数据中台、趋势图表显示
- if (window.shanSetAnalysisVisible) window.shanSetAnalysisVisible(name === 'reports' || name === 'lake' || name === 'analysis');
- }
- 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);
- }
- })();
|