app.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. 'use strict';
  2. // 局域网访问口令:服务端开启 accessToken 时,API 返回 401 会引导输入一次口令,
  3. // 写入 cookie 后所有请求(fetch / 图片 / CSV 链接)自动携带,无需逐处改代码。
  4. (function () {
  5. let tokenAsked = false;
  6. const origFetch = window.fetch.bind(window);
  7. window.fetch = async function (input, init) {
  8. const res = await origFetch(input, init);
  9. const url = typeof input === 'string' ? input : (input && input.url) || String(input || '');
  10. if (res.status === 401 && /\/(api|files)\b/.test(url) && !tokenAsked) {
  11. tokenAsked = true;
  12. const token = window.prompt('此服务已开启访问口令(accessToken),请输入:');
  13. if (token) {
  14. document.cookie = 'accessToken=' + encodeURIComponent(token) + '; path=/; max-age=31536000; SameSite=Lax';
  15. tokenAsked = false;
  16. return origFetch(input, init);
  17. }
  18. }
  19. return res;
  20. };
  21. })();
  22. (function () {
  23. const views = {
  24. crawl: document.getElementById('viewCrawl'),
  25. gen: document.getElementById('viewGen'),
  26. tasks: document.getElementById('viewTasks'),
  27. history: document.getElementById('viewHistory'),
  28. reports: document.getElementById('viewReports'),
  29. lake: document.getElementById('viewLake'),
  30. analysis: document.getElementById('viewAnalysis'),
  31. settings: document.getElementById('viewSettings'),
  32. logs: document.getElementById('viewLogs'),
  33. };
  34. const navItems = document.querySelectorAll('.nav-item[data-view]');
  35. let settingsLoaded = false;
  36. let logTimer = null;
  37. let lastLogSignature = '';
  38. async function loadModels(selectedModel) {
  39. const select = document.getElementById('settingsModel');
  40. const response = await fetch('/api/models');
  41. if (!response.ok) throw new Error('模型列表加载失败');
  42. const data = await response.json();
  43. select.innerHTML = '';
  44. for (const model of data.models || []) {
  45. const option = document.createElement('option');
  46. option.value = typeof model === 'string' ? model : model.id;
  47. option.textContent = typeof model === 'string' ? model : (model.label || model.name || model.id);
  48. option.selected = option.value === selectedModel;
  49. select.appendChild(option);
  50. }
  51. }
  52. async function loadSettings() {
  53. const response = await fetch('/api/settings');
  54. if (!response.ok) throw new Error('设置加载失败');
  55. const settings = await response.json();
  56. document.getElementById('settingsApiKey').value = settings.apiKey || '';
  57. document.getElementById('settingsApiBase').value = settings.apiBase || '';
  58. document.getElementById('settingsRootDir').value = settings.rootDir || '';
  59. document.getElementById('pageSizeInput').value = settings.pageSize || 50;
  60. document.getElementById('delayInput').value = settings.delayMs ?? 300;
  61. const cookieInput = document.getElementById('settingsCookie');
  62. cookieInput.value = '';
  63. cookieInput.placeholder = settings.cookieConfigured
  64. ? '已配置,输入新 Cookie 可更换'
  65. : '登录 melody.shop.ele.me 后从 DevTools 复制';
  66. document.getElementById('settingsChatModel').value = settings.chatModel || 'deepseek-v4.1-flash';
  67. document.getElementById('settingsChatApiBase').value = settings.chatApiBase || '';
  68. const chatKeyInput = document.getElementById('settingsChatApiKey');
  69. chatKeyInput.value = settings.chatApiKey || '';
  70. chatKeyInput.placeholder = settings.chatKeyFallback
  71. ? '留空复用上方 API Key'
  72. : (settings.chatApiKey ? '已配置,输入新 Key 可更换' : '留空复用上方 API Key;否则填对话模型专用 Key');
  73. const tokenInput = document.getElementById('settingsAccessToken');
  74. tokenInput.value = '';
  75. tokenInput.placeholder = settings.accessTokenSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
  76. document.getElementById('settingsAitableEnabled').checked = !!settings.aitableEnabled;
  77. document.getElementById('settingsAitableAppKey').value = settings.aitableAppKey || '';
  78. document.getElementById('settingsAitableBaseId').value = settings.aitableBaseId || '';
  79. document.getElementById('settingsAitableSheetName').value = settings.aitableSheetName || '经营数据';
  80. const aitableSecretInput = document.getElementById('settingsAitableAppSecret');
  81. aitableSecretInput.value = '';
  82. aitableSecretInput.placeholder = settings.aitableSecretSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
  83. await loadModels(settings.model);
  84. }
  85. function setPanelStatus(badgeId, message, ok) {
  86. const badge = document.getElementById(badgeId);
  87. if (!badge) return;
  88. badge.textContent = message;
  89. badge.className = 'badge ' + (ok === true ? 'badge-ok' : ok === false ? 'badge-fail' : 'badge-info');
  90. }
  91. async function saveSettingsPart(fields, buttonId, badgeId, successText = '已保存') {
  92. const button = document.getElementById(buttonId);
  93. button.disabled = true;
  94. setPanelStatus(badgeId, '保存中…');
  95. try {
  96. const response = await fetch('/api/settings', {
  97. method: 'POST',
  98. headers: { 'Content-Type': 'application/json' },
  99. body: JSON.stringify(fields()),
  100. });
  101. const data = await response.json();
  102. if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
  103. await loadSettings();
  104. setPanelStatus(badgeId, successText, true);
  105. } catch (error) {
  106. setPanelStatus(badgeId, error.message, false);
  107. } finally {
  108. button.disabled = false;
  109. }
  110. }
  111. const val = (id) => document.getElementById(id).value.trim();
  112. const hasNewSecret = (id) => { const v = val(id); return v && !v.includes('••••'); };
  113. function saveApiSettings() {
  114. return saveSettingsPart(() => ({
  115. apiKey: val('settingsApiKey'),
  116. model: document.getElementById('settingsModel').value,
  117. chatModel: document.getElementById('settingsChatModel').value,
  118. ...(hasNewSecret('settingsChatApiKey') ? { chatApiKey: val('settingsChatApiKey') } : {}),
  119. }), 'btnSaveApi', 'apiSaveStatus');
  120. }
  121. function saveCrawlSettings() {
  122. return saveSettingsPart(() => ({
  123. rootDir: val('settingsRootDir'),
  124. pageSize: Number(document.getElementById('pageSizeInput').value),
  125. delayMs: Number(document.getElementById('delayInput').value),
  126. ...(val('settingsCookie') ? { cookie: val('settingsCookie') } : {}),
  127. }), 'btnSaveCrawl', 'crawlSaveStatus');
  128. }
  129. function saveSecuritySettings() {
  130. return saveSettingsPart(() => ({
  131. ...(val('settingsAccessToken') ? { accessToken: val('settingsAccessToken') } : {}),
  132. }), 'btnSaveSecurity', 'securitySaveStatus');
  133. }
  134. function saveAitableSettings() {
  135. return saveSettingsPart(() => ({
  136. aitableEnabled: document.getElementById('settingsAitableEnabled').checked,
  137. aitableAppKey: val('settingsAitableAppKey'),
  138. aitableBaseId: val('settingsAitableBaseId'),
  139. aitableSheetName: val('settingsAitableSheetName'),
  140. ...(val('settingsAitableAppSecret') ? { aitableAppSecret: val('settingsAitableAppSecret') } : {}),
  141. }), 'btnSaveAitable', 'aitableSaveStatus');
  142. }
  143. async function testImageApi() {
  144. const status = document.getElementById('imageTestStatus');
  145. const button = document.getElementById('btnTestImage');
  146. button.disabled = true;
  147. status.textContent = '测试中…';
  148. status.className = 'badge badge-info';
  149. try {
  150. const response = await fetch('/api/image/test', { method: 'POST' });
  151. const data = await response.json();
  152. if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
  153. if (data.statusCode === 0) throw new Error(data.error || '网络不可达');
  154. const model = data.model || '';
  155. if (data.statusCode === 200 && data.modelFound) {
  156. status.textContent = '连通正常,模型在列表中';
  157. status.className = 'badge badge-ok';
  158. } else if (data.statusCode === 200 && Array.isArray(data.models)) {
  159. status.textContent = `服务可达(${data.models.length} 个模型);列表中未找到 ${model}`;
  160. status.className = 'badge badge-warn';
  161. } else if (data.statusCode === 200) {
  162. status.textContent = '服务可达;该网关不提供模型列表,无法校验模型名';
  163. status.className = 'badge badge-warn';
  164. } else if (data.statusCode === 401 || data.statusCode === 403) {
  165. status.textContent = `服务返回 ${data.statusCode}:Key 可能无效`;
  166. status.className = 'badge badge-fail';
  167. } else {
  168. status.textContent = `服务返回 ${data.statusCode}`;
  169. status.className = 'badge badge-fail';
  170. }
  171. } catch (error) {
  172. status.textContent = '失败:' + error.message;
  173. status.className = 'badge badge-fail';
  174. } finally {
  175. button.disabled = false;
  176. }
  177. }
  178. async function testChatModel() {
  179. const status = document.getElementById('chatTestStatus');
  180. const button = document.getElementById('btnTestChat');
  181. button.disabled = true;
  182. status.textContent = '测试中…';
  183. status.className = 'badge badge-info';
  184. try {
  185. const response = await fetch('/api/chat/test', {
  186. method: 'POST',
  187. headers: { 'Content-Type': 'application/json' },
  188. body: JSON.stringify({
  189. chatModel: document.getElementById('settingsChatModel').value.trim(),
  190. chatApiKey: document.getElementById('settingsChatApiKey').value.trim(),
  191. }),
  192. });
  193. const data = await response.json();
  194. if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
  195. status.textContent = `连通正常:${data.reply || 'OK'}`;
  196. status.className = 'badge badge-ok';
  197. } catch (error) {
  198. status.textContent = '失败:' + error.message;
  199. status.className = 'badge badge-fail';
  200. } finally {
  201. button.disabled = false;
  202. }
  203. }
  204. function bindSettingsTabs() {
  205. const tabs = document.getElementById('settingsApiTabs');
  206. if (!tabs) return;
  207. tabs.addEventListener('click', (event) => {
  208. const btn = event.target.closest('button[data-tab]');
  209. if (!btn || btn.classList.contains('active')) return;
  210. tabs.querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
  211. document.getElementById('settingsTabImage').hidden = btn.dataset.tab !== 'image';
  212. document.getElementById('settingsTabChat').hidden = btn.dataset.tab !== 'chat';
  213. });
  214. }
  215. // 云端动作(测试/推/拉);badgeId 允许不同页面各自展示状态(设置页/数据采集页/数据中台页)
  216. async function aitableAction(kind, badgeId = 'aitableStatus', body = {}) {
  217. const status = document.getElementById(badgeId);
  218. const labels = { test: '测试连通中…', push: '推送中…', pull: '拉取中…' };
  219. if (status) {
  220. status.textContent = labels[kind];
  221. status.className = 'badge badge-info';
  222. }
  223. try {
  224. const res = await fetch(`/api/reports/aitable/${kind}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
  225. const data = await res.json();
  226. if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
  227. if (kind === 'test') {
  228. if (status) { status.textContent = '连通正常'; status.className = 'badge badge-ok'; }
  229. return data;
  230. }
  231. if (kind === 'push') {
  232. const stale = data.skippedStale ? `,跳过云端较新 ${data.skippedStale} 行` : '';
  233. if (status) { status.textContent = `已推送 ${data.pushed} 行(新增 ${data.created} / 更新 ${data.updated}${stale})`; status.className = 'badge badge-ok'; }
  234. } else {
  235. const detail = (data.accounts || []).map((a) => `${a.account} ${a.rows}行`).join('、') || '无数据';
  236. if (status) { status.textContent = `已拉取 ${data.pulled} 行:${detail}`; status.className = 'badge badge-ok'; }
  237. }
  238. return data;
  239. } catch (error) {
  240. if (status) { status.textContent = error.message; status.className = 'badge badge-fail'; }
  241. throw error;
  242. }
  243. }
  244. window.shanAitableAction = aitableAction;
  245. async function initSettings() {
  246. if (settingsLoaded) return;
  247. bindSettingsTabs();
  248. if (new URLSearchParams(location.search).get('tab') === 'chat') {
  249. document.querySelector('#settingsApiTabs button[data-tab="chat"]')?.click();
  250. }
  251. document.getElementById('btnSaveApi').addEventListener('click', saveApiSettings);
  252. document.getElementById('btnSaveCrawl').addEventListener('click', saveCrawlSettings);
  253. document.getElementById('btnSaveSecurity').addEventListener('click', saveSecuritySettings);
  254. document.getElementById('btnSaveAitable').addEventListener('click', saveAitableSettings);
  255. document.getElementById('btnTestImage').addEventListener('click', testImageApi);
  256. document.getElementById('btnTestChat').addEventListener('click', testChatModel);
  257. document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test'));
  258. document.getElementById('btnClearLog').addEventListener('click', () => {
  259. clearLogs().catch(() => {});
  260. });
  261. try {
  262. await loadSettings();
  263. settingsLoaded = true;
  264. } catch (error) {
  265. setPanelStatus('apiSaveStatus', error.message, false);
  266. }
  267. }
  268. function escapeLogText(value) {
  269. const div = document.createElement('div');
  270. div.textContent = String(value ?? '');
  271. return div.innerHTML;
  272. }
  273. function renderLogs(logs) {
  274. const panel = document.getElementById('logPanel');
  275. if (!panel) return;
  276. const sourceMap = { task: '任务', system: '系统' };
  277. const levelMap = { info: 'INFO', success: 'OK', warn: 'WARN', error: 'FAIL' };
  278. panel.innerHTML = logs.map((log) => {
  279. const level = levelMap[log.level] || 'INFO';
  280. const levelClass = log.level === 'success' ? 'ok' : log.level || 'info';
  281. return `
  282. <div class="log-line">
  283. <span class="log-ts">${escapeLogText(log.time)}</span>
  284. <span class="log-source ${log.source === 'system' ? 'system' : 'task'}">${sourceMap[log.source] || log.source}</span>
  285. <span class="log-lvl ${levelClass}">${level}</span>
  286. <span class="log-msg">${escapeLogText(log.message)}</span>
  287. </div>`;
  288. }).join('');
  289. }
  290. async function refreshLogs() {
  291. const response = await fetch('/api/logs');
  292. if (!response.ok) return;
  293. const data = await response.json();
  294. const logs = (data.logs || []).sort((a, b) => b.seq - a.seq);
  295. const signature = logs.map((log) => `${log.seq}:${log.level}:${log.message}`).join('|');
  296. if (signature === lastLogSignature) return;
  297. lastLogSignature = signature;
  298. renderLogs(logs);
  299. }
  300. function startLogPolling() {
  301. if (logTimer) return;
  302. refreshLogs();
  303. logTimer = setInterval(refreshLogs, 1500);
  304. }
  305. function stopLogPolling() {
  306. if (logTimer) { clearInterval(logTimer); logTimer = null; }
  307. }
  308. async function clearLogs() {
  309. await fetch('/api/logs/clear', { method: 'POST' });
  310. lastLogSignature = '';
  311. await refreshLogs();
  312. }
  313. function switchView(name) {
  314. for (const [key, el] of Object.entries(views)) {
  315. el.classList.toggle('active', key === name);
  316. }
  317. navItems.forEach((n) => n.classList.toggle('active', n.dataset.view === name));
  318. if (name === 'crawl' && window.shanCrawlInit) window.shanCrawlInit();
  319. if (name === 'gen' && window.shanGenInit) window.shanGenInit();
  320. if (name === 'tasks' && window.shanTasksInit) window.shanTasksInit();
  321. if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
  322. if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
  323. if (name === 'lake' && window.shanLakeInit) window.shanLakeInit();
  324. if (name === 'analysis' && window.shanAnalysisActivate) window.shanAnalysisActivate();
  325. if (name === 'settings') initSettings();
  326. // 系统日志:仅日志页激活时轮询
  327. if (name === 'logs') startLogPolling();
  328. else stopLogPolling();
  329. // 智能问数机器人:仅经营分析相关页面显示
  330. if (window.shanSetAnalysisVisible) window.shanSetAnalysisVisible(name === 'reports' || name === 'analysis');
  331. }
  332. navItems.forEach((n) => n.addEventListener('click', () => switchView(n.dataset.view)));
  333. const requestedView = new URLSearchParams(location.search).get('view');
  334. switchView(views[requestedView] ? requestedView : 'crawl');
  335. const splash = document.getElementById('splash');
  336. if (splash) {
  337. setTimeout(() => splash.classList.add('hide'), 1800);
  338. setTimeout(() => splash.remove(), 2400);
  339. }
  340. })();