app.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. 'use strict';
  2. // 局域网访问口令:服务端开启 accessToken 时,API 返回 401 会引导输入口令,
  3. // 写入 cookie 后所有请求(fetch / 图片 / CSV 链接)自动携带,无需逐处改代码。
  4. // 并发 401 共享同一次弹窗;用户取消只结束本轮,后续 401 仍可再次询问。
  5. (function () {
  6. const origFetch = window.fetch.bind(window);
  7. let tokenPromise = null;
  8. window.fetch = async function (input, init) {
  9. const res = await origFetch(input, init);
  10. const url = typeof input === 'string' ? input : (input && input.url) || String(input || '');
  11. if (res.status === 401 && /\/(api|files)\b/.test(url)) {
  12. if (!tokenPromise) {
  13. tokenPromise = (async () => {
  14. const token = window.prompt('此服务已开启访问口令(accessToken),请输入:');
  15. if (token) {
  16. document.cookie = 'accessToken=' + encodeURIComponent(token) + '; path=/; max-age=31536000; SameSite=Lax';
  17. }
  18. tokenPromise = null; // 本轮结束;取消后下次 401 仍会询问
  19. return token || '';
  20. })();
  21. }
  22. const token = await tokenPromise;
  23. if (token) return origFetch(input, init);
  24. }
  25. return res;
  26. };
  27. })();
  28. (function () {
  29. const views = {
  30. crawl: document.getElementById('viewCrawl'),
  31. gen: document.getElementById('viewGen'),
  32. tasks: document.getElementById('viewTasks'),
  33. history: document.getElementById('viewHistory'),
  34. reports: document.getElementById('viewReports'),
  35. cockpit: document.getElementById('viewCockpit'),
  36. report: document.getElementById('viewReport'),
  37. ops: document.getElementById('viewOps'),
  38. schools: document.getElementById('viewSchools'),
  39. lake: document.getElementById('viewLake'),
  40. analysis: document.getElementById('viewAnalysis'),
  41. ask: document.getElementById('viewAsk'),
  42. settings: document.getElementById('viewSettings'),
  43. logs: document.getElementById('viewLogs'),
  44. };
  45. const navItems = document.querySelectorAll('.nav-item[data-view]');
  46. let settingsBound = false;
  47. let settingsLoaded = false;
  48. let logTimer = null;
  49. let lastLogSignature = '';
  50. async function loadModels(selectedModel) {
  51. const select = document.getElementById('settingsModel');
  52. const response = await fetch('/api/models');
  53. if (!response.ok) throw new Error('模型列表加载失败');
  54. const data = await response.json();
  55. select.innerHTML = '';
  56. for (const model of data.models || []) {
  57. const option = document.createElement('option');
  58. option.value = typeof model === 'string' ? model : model.id;
  59. option.textContent = typeof model === 'string' ? model : (model.label || model.name || model.id);
  60. option.selected = option.value === selectedModel;
  61. select.appendChild(option);
  62. }
  63. }
  64. async function loadSettings() {
  65. const response = await fetch('/api/settings');
  66. if (!response.ok) throw new Error('设置加载失败');
  67. const settings = await response.json();
  68. document.getElementById('settingsApiKey').value = settings.apiKey || '';
  69. document.getElementById('settingsApiBase').value = settings.apiBase || '';
  70. document.getElementById('settingsRootDir').value = settings.rootDir || '';
  71. document.getElementById('pageSizeInput').value = settings.pageSize || 50;
  72. document.getElementById('delayInput').value = settings.delayMs ?? 300;
  73. const cookieInput = document.getElementById('settingsCookie');
  74. cookieInput.value = '';
  75. cookieInput.placeholder = settings.cookieConfigured
  76. ? '已配置,输入新 Cookie 可更换'
  77. : '登录 melody.shop.ele.me 后从 DevTools 复制';
  78. document.getElementById('settingsChatModel').value = settings.chatModel || 'deepseek-v4.1-flash';
  79. document.getElementById('settingsChatApiBase').value = settings.chatApiBase || '';
  80. document.getElementById('settingsChatModelAgent').value = settings.chatModelAgent || '';
  81. document.getElementById('settingsChatJsonMode').checked = !!settings.chatJsonMode;
  82. const chatKeyInput = document.getElementById('settingsChatApiKey');
  83. chatKeyInput.value = settings.chatApiKey || '';
  84. chatKeyInput.placeholder = settings.chatKeyFallback
  85. ? '留空复用上方 API Key'
  86. : (settings.chatApiKey ? '已配置,输入新 Key 可更换' : '留空复用上方 API Key;否则填对话模型专用 Key');
  87. const tokenInput = document.getElementById('settingsAccessToken');
  88. tokenInput.value = '';
  89. tokenInput.placeholder = settings.accessTokenSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
  90. document.getElementById('settingsAitableEnabled').checked = !!settings.aitableEnabled;
  91. document.getElementById('settingsAitableReadOnly').checked = !!settings.aitableReadOnly;
  92. document.getElementById('settingsAitableAppKey').value = settings.aitableAppKey || '';
  93. document.getElementById('settingsAitableBaseId').value = settings.aitableBaseId || '';
  94. document.getElementById('settingsAitableSheetDetail').value = settings.aitableSheetDetail || '门店明细';
  95. document.getElementById('settingsAitableSheetChain').value = settings.aitableSheetChain || '连锁汇总';
  96. document.getElementById('settingsAitableSheetCompany').value = settings.aitableSheetCompany || '公司汇总';
  97. const aitableSecretInput = document.getElementById('settingsAitableAppSecret');
  98. aitableSecretInput.value = '';
  99. aitableSecretInput.placeholder = settings.aitableSecretSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
  100. await loadModels(settings.model);
  101. await loadStorageSettings();
  102. }
  103. function setPanelStatus(badgeId, message, ok) {
  104. const badge = document.getElementById(badgeId);
  105. if (!badge) return;
  106. badge.textContent = message;
  107. badge.className = 'badge ' + (ok === true ? 'badge-ok' : ok === false ? 'badge-fail' : 'badge-info');
  108. }
  109. async function saveSettingsPart(fields, buttonId, badgeId, successText = '已保存') {
  110. const button = document.getElementById(buttonId);
  111. button.disabled = true;
  112. setPanelStatus(badgeId, '保存中…');
  113. try {
  114. const response = await fetch('/api/settings', {
  115. method: 'POST',
  116. headers: { 'Content-Type': 'application/json' },
  117. body: JSON.stringify(fields()),
  118. });
  119. const data = await response.json();
  120. if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
  121. await loadSettings();
  122. setPanelStatus(badgeId, successText, true);
  123. } catch (error) {
  124. setPanelStatus(badgeId, error.message, false);
  125. } finally {
  126. button.disabled = false;
  127. }
  128. }
  129. const val = (id) => document.getElementById(id).value.trim();
  130. const hasNewSecret = (id) => { const v = val(id); return v && !v.includes('••••'); };
  131. function saveApiSettings() {
  132. return saveSettingsPart(() => ({
  133. apiKey: val('settingsApiKey'),
  134. model: document.getElementById('settingsModel').value,
  135. chatModel: document.getElementById('settingsChatModel').value,
  136. chatModelAgent: document.getElementById('settingsChatModelAgent').value,
  137. chatJsonMode: document.getElementById('settingsChatJsonMode').checked,
  138. ...(hasNewSecret('settingsChatApiKey') ? { chatApiKey: val('settingsChatApiKey') } : {}),
  139. }), 'btnSaveApi', 'apiSaveStatus');
  140. }
  141. function saveCrawlSettings() {
  142. return saveSettingsPart(() => ({
  143. rootDir: val('settingsRootDir'),
  144. pageSize: Number(document.getElementById('pageSizeInput').value),
  145. delayMs: Number(document.getElementById('delayInput').value),
  146. ...(val('settingsCookie') ? { cookie: val('settingsCookie') } : {}),
  147. }), 'btnSaveCrawl', 'crawlSaveStatus');
  148. }
  149. function saveSecuritySettings() {
  150. return saveSettingsPart(() => ({
  151. ...(val('settingsAccessToken') ? { accessToken: val('settingsAccessToken') } : {}),
  152. }), 'btnSaveSecurity', 'securitySaveStatus');
  153. }
  154. function saveAitableSettings() {
  155. return saveSettingsPart(() => ({
  156. aitableEnabled: document.getElementById('settingsAitableEnabled').checked,
  157. aitableReadOnly: document.getElementById('settingsAitableReadOnly').checked,
  158. aitableAppKey: val('settingsAitableAppKey'),
  159. aitableBaseId: val('settingsAitableBaseId'),
  160. aitableSheetDetail: val('settingsAitableSheetDetail'),
  161. aitableSheetChain: val('settingsAitableSheetChain'),
  162. aitableSheetCompany: val('settingsAitableSheetCompany'),
  163. ...(val('settingsAitableAppSecret') ? { aitableAppSecret: val('settingsAitableAppSecret') } : {}),
  164. }), 'btnSaveAitable', 'aitableSaveStatus');
  165. }
  166. async function testImageApi() {
  167. const status = document.getElementById('imageTestStatus');
  168. const button = document.getElementById('btnTestImage');
  169. button.disabled = true;
  170. status.textContent = '测试中…';
  171. status.className = 'badge badge-info';
  172. try {
  173. const response = await fetch('/api/image/test', { method: 'POST' });
  174. const data = await response.json();
  175. if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
  176. if (data.statusCode === 0) throw new Error(data.error || '网络不可达');
  177. const model = data.model || '';
  178. if (data.statusCode === 200 && data.modelFound) {
  179. status.textContent = '连通正常,模型在列表中';
  180. status.className = 'badge badge-ok';
  181. } else if (data.statusCode === 200 && Array.isArray(data.models)) {
  182. status.textContent = `服务可达(${data.models.length} 个模型);列表中未找到 ${model}`;
  183. status.className = 'badge badge-warn';
  184. } else if (data.statusCode === 200) {
  185. status.textContent = '服务可达;该网关不提供模型列表,无法校验模型名';
  186. status.className = 'badge badge-warn';
  187. } else if (data.statusCode === 401 || data.statusCode === 403) {
  188. status.textContent = `服务返回 ${data.statusCode}:Key 可能无效`;
  189. status.className = 'badge badge-fail';
  190. } else {
  191. status.textContent = `服务返回 ${data.statusCode}`;
  192. status.className = 'badge badge-fail';
  193. }
  194. } catch (error) {
  195. status.textContent = '失败:' + error.message;
  196. status.className = 'badge badge-fail';
  197. } finally {
  198. button.disabled = false;
  199. }
  200. }
  201. async function testChatModel() {
  202. const status = document.getElementById('chatTestStatus');
  203. const button = document.getElementById('btnTestChat');
  204. button.disabled = true;
  205. status.textContent = '测试中…';
  206. status.className = 'badge badge-info';
  207. try {
  208. const response = await fetch('/api/chat/test', {
  209. method: 'POST',
  210. headers: { 'Content-Type': 'application/json' },
  211. body: JSON.stringify({
  212. chatModel: document.getElementById('settingsChatModel').value.trim(),
  213. chatApiKey: document.getElementById('settingsChatApiKey').value.trim(),
  214. }),
  215. });
  216. const data = await response.json();
  217. if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
  218. status.textContent = `连通正常:${data.reply || 'OK'}`;
  219. status.className = 'badge badge-ok';
  220. } catch (error) {
  221. status.textContent = '失败:' + error.message;
  222. status.className = 'badge badge-fail';
  223. } finally {
  224. button.disabled = false;
  225. }
  226. }
  227. function bindSettingsTabs() {
  228. const tabs = document.getElementById('settingsApiTabs');
  229. if (!tabs) return;
  230. tabs.addEventListener('click', (event) => {
  231. const btn = event.target.closest('button[data-tab]');
  232. if (!btn || btn.classList.contains('active')) return;
  233. tabs.querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
  234. document.getElementById('settingsTabImage').hidden = btn.dataset.tab !== 'image';
  235. document.getElementById('settingsTabChat').hidden = btn.dataset.tab !== 'chat';
  236. });
  237. }
  238. // 云端动作(测试/推/拉);badgeId 允许不同页面各自展示状态(设置页/数据采集页/数据中台页)
  239. async function aitableAction(kind, badgeId = 'aitableStatus', body = {}) {
  240. const status = document.getElementById(badgeId);
  241. const labels = { test: '测试连通中…', push: '推送中…', pull: '拉取中…' };
  242. if (status) {
  243. status.textContent = labels[kind];
  244. status.className = 'badge badge-info';
  245. }
  246. try {
  247. const res = await fetch(`/api/reports/aitable/${kind}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
  248. const data = await res.json();
  249. if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
  250. if (kind === 'test') {
  251. // 逐表探测:缺哪张表报哪张(testConnection 返回 { ok, sheets: { detail/chain/company: {name, ok, hint} } })
  252. const failed = Object.values(data.sheets || {}).filter((s) => !s.ok);
  253. if (status) {
  254. if (data.ok) { status.textContent = '三张表连通正常'; status.className = 'badge badge-ok'; }
  255. else {
  256. status.textContent = `不可读:${failed.map((s) => s.name).join('、')}`;
  257. status.className = 'badge badge-fail';
  258. }
  259. }
  260. return data;
  261. }
  262. if (kind === 'push') {
  263. if (data.pushed === 0) {
  264. if (status) { status.textContent = '本地与云端一致,无需推送'; status.className = 'badge badge-ok'; }
  265. } else {
  266. const stale = data.skippedStale ? `,跳过云端较新 ${data.skippedStale} 行` : '';
  267. if (status) { status.textContent = `已推送 ${data.pushed} 行(新增 ${data.created} / 更新 ${data.updated}${stale})`; status.className = 'badge badge-ok'; }
  268. }
  269. } else {
  270. const detail = (data.accounts || []).map((a) => `${a.account} ${a.rows}行`).join('、') || '无数据';
  271. if (status) { status.textContent = `已拉取 ${data.pulled} 行:${detail}`; status.className = 'badge badge-ok'; }
  272. }
  273. return data;
  274. } catch (error) {
  275. if (status) { status.textContent = error.message; status.className = 'badge badge-fail'; }
  276. throw error;
  277. }
  278. }
  279. window.shanAitableAction = aitableAction;
  280. async function loadStorageSettings() {
  281. try {
  282. const response = await fetch('/api/settings/storage');
  283. if (!response.ok) return;
  284. const st = await response.json();
  285. document.getElementById('settingsStorageDriver').value = st.driver || 'sqlite';
  286. document.getElementById('settingsPgHost').value = st.postgres.host || '';
  287. document.getElementById('settingsPgPort').value = st.postgres.port || 5432;
  288. document.getElementById('settingsPgDatabase').value = st.postgres.database || '';
  289. document.getElementById('settingsPgUser').value = st.postgres.user || '';
  290. document.getElementById('settingsPgSsl').checked = !!st.postgres.ssl;
  291. const pw = document.getElementById('settingsPgPassword');
  292. pw.value = '';
  293. pw.placeholder = st.postgres.passwordSet ? '已设置,留空保持不变' : '数据库密码';
  294. toggleStorageFields();
  295. if (!st.pgInstalled) setPanelStatus('storageTestStatus', '未安装 pg 驱动', false);
  296. } catch { /* 读取失败不阻塞设置页 */ }
  297. }
  298. function toggleStorageFields() {
  299. const isPg = document.getElementById('settingsStorageDriver').value === 'postgres';
  300. document.getElementById('settingsPgFields').hidden = !isPg;
  301. }
  302. function storagePayload() {
  303. return {
  304. driver: document.getElementById('settingsStorageDriver').value,
  305. pgHost: val('settingsPgHost'),
  306. pgPort: Number(val('settingsPgPort')) || 5432,
  307. pgDatabase: val('settingsPgDatabase'),
  308. pgUser: val('settingsPgUser'),
  309. pgPassword: document.getElementById('settingsPgPassword').value,
  310. pgSsl: document.getElementById('settingsPgSsl').checked,
  311. };
  312. }
  313. async function testStorage() {
  314. const btn = document.getElementById('btnTestStorage');
  315. btn.disabled = true;
  316. setPanelStatus('storageTestStatus', '连接中…');
  317. try {
  318. const response = await fetch('/api/settings/storage/test', {
  319. method: 'POST',
  320. headers: { 'Content-Type': 'application/json' },
  321. body: JSON.stringify(storagePayload()),
  322. });
  323. const data = await response.json();
  324. if (data.ok) setPanelStatus('storageTestStatus', `连接成功 · ${data.version || ''}`, true);
  325. else setPanelStatus('storageTestStatus', data.error || '连接失败', false);
  326. } catch (error) {
  327. setPanelStatus('storageTestStatus', error.message, false);
  328. } finally {
  329. btn.disabled = false;
  330. }
  331. }
  332. async function saveStorageSettings() {
  333. const btn = document.getElementById('btnSaveStorage');
  334. btn.disabled = true;
  335. setPanelStatus('storageSaveStatus', '保存中…');
  336. try {
  337. const response = await fetch('/api/settings/storage', {
  338. method: 'POST',
  339. headers: { 'Content-Type': 'application/json' },
  340. body: JSON.stringify(storagePayload()),
  341. });
  342. const data = await response.json();
  343. if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
  344. setPanelStatus('storageSaveStatus', data.restartRequired ? '已保存,重启服务后生效' : '已保存', true);
  345. await loadStorageSettings();
  346. } catch (error) {
  347. setPanelStatus('storageSaveStatus', error.message, false);
  348. } finally {
  349. btn.disabled = false;
  350. }
  351. }
  352. async function initSettings() {
  353. // 事件绑定与数据加载分离:加载失败重进设置页时不会重复 addEventListener
  354. if (!settingsBound) {
  355. settingsBound = true;
  356. bindSettingsTabs();
  357. if (new URLSearchParams(location.search).get('tab') === 'chat') {
  358. document.querySelector('#settingsApiTabs button[data-tab="chat"]')?.click();
  359. }
  360. document.getElementById('btnSaveApi').addEventListener('click', saveApiSettings);
  361. document.getElementById('btnSaveCrawl').addEventListener('click', saveCrawlSettings);
  362. document.getElementById('btnSaveSecurity').addEventListener('click', saveSecuritySettings);
  363. document.getElementById('btnSaveAitable').addEventListener('click', saveAitableSettings);
  364. document.getElementById('btnTestImage').addEventListener('click', testImageApi);
  365. document.getElementById('btnTestChat').addEventListener('click', testChatModel);
  366. document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test'));
  367. document.getElementById('btnSaveStorage').addEventListener('click', saveStorageSettings);
  368. document.getElementById('btnTestStorage').addEventListener('click', testStorage);
  369. document.getElementById('settingsStorageDriver').addEventListener('change', toggleStorageFields);
  370. const bindHelpPopover = (btnId, popId) => {
  371. const btn = document.getElementById(btnId);
  372. const pop = document.getElementById(popId);
  373. btn.addEventListener('click', (event) => {
  374. event.stopPropagation();
  375. pop.hidden = !pop.hidden;
  376. btn.classList.toggle('active', !pop.hidden);
  377. btn.setAttribute('aria-expanded', String(!pop.hidden));
  378. });
  379. document.addEventListener('click', (event) => {
  380. if (!pop.hidden && !event.target.closest('.conn-help-wrap')) {
  381. pop.hidden = true;
  382. btn.classList.remove('active');
  383. btn.setAttribute('aria-expanded', 'false');
  384. }
  385. });
  386. };
  387. bindHelpPopover('btnSecurityHelp', 'securityHelpPopover');
  388. bindHelpPopover('btnAitableHelp', 'aitableHelpPopover');
  389. document.getElementById('btnClearLog').addEventListener('click', () => {
  390. clearLogs().catch(() => {});
  391. });
  392. }
  393. if (!settingsLoaded) {
  394. try {
  395. await loadSettings();
  396. settingsLoaded = true;
  397. } catch (error) {
  398. setPanelStatus('apiSaveStatus', error.message, false);
  399. }
  400. }
  401. }
  402. function escapeLogText(value) {
  403. const div = document.createElement('div');
  404. div.textContent = String(value ?? '');
  405. return div.innerHTML;
  406. }
  407. function renderLogs(logs) {
  408. const panel = document.getElementById('logPanel');
  409. if (!panel) return;
  410. const sourceMap = { task: '任务', system: '系统' };
  411. const levelMap = { info: 'INFO', success: 'OK', warn: 'WARN', error: 'FAIL' };
  412. panel.innerHTML = logs.map((log) => {
  413. const level = levelMap[log.level] || 'INFO';
  414. const levelClass = log.level === 'success' ? 'ok' : log.level || 'info';
  415. return `
  416. <div class="log-line">
  417. <span class="log-ts">${escapeLogText(log.time)}</span>
  418. <span class="log-source ${log.source === 'system' ? 'system' : 'task'}">${sourceMap[log.source] || log.source}</span>
  419. <span class="log-lvl ${levelClass}">${level}</span>
  420. <span class="log-msg">${escapeLogText(log.message)}</span>
  421. </div>`;
  422. }).join('');
  423. }
  424. async function refreshLogs() {
  425. const response = await fetch('/api/logs');
  426. if (!response.ok) return;
  427. const data = await response.json();
  428. const logs = (data.logs || []).sort((a, b) => (b.seq || 0) - (a.seq || 0));
  429. const signature = logs.map((log) => `${log.seq}:${log.level}:${log.message}`).join('|');
  430. if (signature === lastLogSignature) return;
  431. lastLogSignature = signature;
  432. renderLogs(logs);
  433. }
  434. function startLogPolling() {
  435. if (logTimer) return;
  436. refreshLogs();
  437. logTimer = setInterval(refreshLogs, 1500);
  438. }
  439. function stopLogPolling() {
  440. if (logTimer) { clearInterval(logTimer); logTimer = null; }
  441. }
  442. async function clearLogs() {
  443. await fetch('/api/logs/clear', { method: 'POST' });
  444. lastLogSignature = '';
  445. await refreshLogs();
  446. }
  447. function switchView(name) {
  448. for (const [key, el] of Object.entries(views)) {
  449. el.classList.toggle('active', key === name);
  450. }
  451. navItems.forEach((n) => n.classList.toggle('active', n.dataset.view === name));
  452. if (name === 'crawl' && window.shanCrawlInit) window.shanCrawlInit();
  453. if (window.shanCrawlSetActive) window.shanCrawlSetActive(name === 'crawl'); // 离开采集页暂停状态轮询
  454. if (name === 'gen' && window.shanGenInit) window.shanGenInit();
  455. if (name === 'tasks' && window.shanTasksInit) window.shanTasksInit();
  456. if (window.shanTasksSetActive) window.shanTasksSetActive(name === 'tasks'); // 离开任务页清掉轮询
  457. if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
  458. if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
  459. if (name === 'cockpit' && window.shanCockpitInit) window.shanCockpitInit();
  460. if (name === 'report' && window.shanReportInit) window.shanReportInit();
  461. if (name === 'ops' && window.shanOpsInit) window.shanOpsInit();
  462. if (name === 'schools' && window.shanSchoolsInit) window.shanSchoolsInit();
  463. if (name === 'lake' && window.shanLakeInit) window.shanLakeInit(); // 明细 tab 激活时再懒加载报表
  464. if (name === 'analysis' && window.shanAnalysisActivate) window.shanAnalysisActivate();
  465. if (name === 'ask' && window.shanAskInit) window.shanAskInit();
  466. if (name === 'settings') initSettings();
  467. // 系统日志:仅日志页激活时轮询
  468. if (name === 'logs') startLogPolling();
  469. else stopLogPolling();
  470. }
  471. navItems.forEach((n) => n.addEventListener('click', () => switchView(n.dataset.view)));
  472. // 隐藏诊断入口:连点侧边栏版本号 5 次(2 秒内不重置计数)显现数据采集页的「取数诊断」按钮。
  473. // 开发者功能,常规用户不可见;页面刷新后重新隐藏。
  474. {
  475. const ver = document.getElementById('sidebarVersion');
  476. const diagBtn = document.getElementById('btnNaposDiagnose');
  477. if (ver && diagBtn) {
  478. const verText = ver.textContent;
  479. let clicks = 0, resetTimer = null, revertTimer = null;
  480. ver.style.cursor = 'default';
  481. ver.addEventListener('click', () => {
  482. if (!diagBtn.hidden) return;
  483. clicks += 1;
  484. clearTimeout(resetTimer);
  485. resetTimer = setTimeout(() => { clicks = 0; }, 2000);
  486. clearTimeout(revertTimer);
  487. if (clicks >= 3 && clicks < 5) {
  488. ver.textContent = `再点 ${5 - clicks} 次开启诊断`;
  489. revertTimer = setTimeout(() => { ver.textContent = verText; }, 2500);
  490. }
  491. if (clicks >= 5) {
  492. clicks = 0;
  493. ver.textContent = '诊断已开启';
  494. revertTimer = setTimeout(() => { ver.textContent = verText; }, 2000);
  495. diagBtn.hidden = false;
  496. const harvestBtn = document.getElementById('btnHarvest');
  497. if (harvestBtn) harvestBtn.hidden = false;
  498. }
  499. });
  500. }
  501. }
  502. const requestedView = new URLSearchParams(location.search).get('view');
  503. switchView(views[requestedView] ? requestedView : 'crawl');
  504. const splash = document.getElementById('splash');
  505. if (splash) {
  506. setTimeout(() => splash.classList.add('hide'), 1800);
  507. setTimeout(() => splash.remove(), 2400);
  508. }
  509. })();