| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{% block title %}Gemini API 代理{% endblock %} - 管理界面</title> |
| <link rel="icon" href="/favicon.ico" type="image/x-icon"> |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css"> {# Flatpickr CSS #} |
| <script src="https://cdn.jsdelivr.net/npm/flatpickr"></script> {# Flatpickr JS #} |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/l10n/zh.js"></script> {# Flatpickr 中文语言包 #} |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> {# 全局加载 Chart.js #} |
| <script> |
| |
| const rawRoutesFromUrlFor = { |
| root_get: "/", |
| login_post: "/login", |
| |
| |
| manage_context_page: "/manage/context", |
| get_manage_context_data: "/api/manage/context/data", |
| update_context_ttl: "/api/manage/context/ttl", |
| delete_context_entry: "/api/manage/context/delete", |
| manage_keys_page: "/manage/keys", |
| get_manage_keys_data: "/api/manage/keys/data", |
| add_api_key: "/api/keys", |
| update_api_key: "/api/keys", |
| delete_api_key: "/api/keys", |
| manage_report_page: "/manage/report", |
| get_report_data: "/api/report/data", |
| get_api_v2_model_info: "/api/v2/models", |
| swagger_ui_html: "/docs", |
| redoc_html: "/redoc" |
| }; |
| console.log("[DEBUG] Raw routes from url_for:", JSON.stringify(rawRoutesFromUrlFor)); |
| |
| |
| window.APP_ROUTES = {}; |
| for (const key in rawRoutesFromUrlFor) { |
| const originalUrlString = rawRoutesFromUrlFor[key]; |
| try { |
| if (originalUrlString && typeof originalUrlString === 'string') { |
| |
| const urlObject = new URL(originalUrlString, window.location.origin); |
| window.APP_ROUTES[key] = urlObject.pathname + urlObject.search + urlObject.hash; |
| } else { |
| console.warn("[DEBUG] Invalid originalUrlString for key:", key, "value:", originalUrlString); |
| window.APP_ROUTES[key] = originalUrlString; |
| } |
| } catch (e) { |
| console.error("[DEBUG] Error processing route for key:", key, "value:", originalUrlString, "Error:", e); |
| window.APP_ROUTES[key] = originalUrlString; |
| } |
| } |
| console.log("[DEBUG] Processed APP_ROUTES by Roo:", JSON.stringify(window.APP_ROUTES)); |
| </script> |
| <style> |
| body { display: flex; flex-direction: column; min-height: 100vh; } |
| .container { flex-grow: 1; } |
| footer { background-color: #f8f9fa; padding: 1rem 0; text-align: center; } |
| header nav a, header nav span { margin-left: 0.5rem; margin-right: 0.5rem; } |
| |
| |
| header.bg-primary { |
| padding-top: 1rem; |
| padding-bottom: 1rem; |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); |
| } |
| |
| header h1 { |
| font-size: 1.75rem; |
| margin-bottom: 0; |
| } |
| |
| |
| .brand-title { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| font-size: 2.2rem; |
| font-weight: bold; |
| color: #fff; |
| text-shadow: 1px 1px 2px rgba(0,0,0,0.3), |
| 0 0 10px rgba(255,255,255,0.3); |
| letter-spacing: 1px; |
| margin-right: 1rem; |
| } |
| @media (max-width: 768px) { |
| .brand-title { |
| font-size: 1.8rem; |
| margin-bottom: 0.5rem; |
| width: 100%; |
| text-align: center; |
| } |
| } |
| |
| #main-nav a.spa-link, |
| #main-nav span#admin-nav-links-placeholder a, |
| #main-nav a[data-no-spa] { |
| padding: 0.5rem 0.75rem; |
| border-radius: 0.25rem; |
| transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; |
| text-decoration: none; |
| color: #fff; |
| } |
| |
| #main-nav a.spa-link:hover, |
| #main-nav span#admin-nav-links-placeholder a:hover, |
| #main-nav a[data-no-spa]:hover { |
| background-color: rgba(255, 255, 255, 0.1); |
| color: #fff; |
| } |
| |
| #main-nav a.spa-link.active, |
| #main-nav span#admin-nav-links-placeholder a.active { |
| background-color: rgba(255, 255, 255, 0.2); |
| font-weight: bold; |
| } |
| |
| |
| #logout-button { |
| color: #ffc107 !important; |
| } |
| #logout-button:hover { |
| background-color: rgba(255, 193, 7, 0.2) !important; |
| color: #fff !important; |
| } |
| |
| @media (max-width: 768px) { |
| header .container-fluid { |
| flex-direction: column; |
| align-items: flex-start; |
| } |
| header h1 { |
| margin-bottom: 0.5rem; |
| } |
| header nav { |
| flex-direction: column; |
| align-items: flex-start; |
| width: 100%; |
| } |
| header nav a, |
| header nav span, |
| #main-nav span#admin-nav-links-placeholder a { |
| margin-left: 0 !important; |
| margin-right: 0 !important; |
| margin-top: 0.5rem; |
| display: block; |
| width: 100%; |
| padding: 0.5rem 0.75rem; |
| } |
| #main-nav span#admin-nav-links-placeholder { |
| flex-direction: column; |
| width: 100%; |
| } |
| } |
| |
| |
| #main-content-area #page-content-wrapper > h2:first-child, |
| #main-content-area > .container > h2:first-child, |
| #main-content-area > h2:first-child { |
| font-size: 2.25rem; |
| font-weight: 600; |
| margin-top: 0.5rem; |
| margin-bottom: 2rem; |
| padding-bottom: 0.5rem; |
| border-bottom: 2px solid #0d6efd; |
| color: #2c3e50; |
| line-height: 1.3; |
| } |
| |
| |
| </style> |
| </head> |
| <body> |
| <header class="bg-primary text-white p-3 mb-4"> {# p-3 可能会被上面的 padding-top/bottom 覆盖,但保留以防万一 #} |
| <div class="container-fluid d-flex justify-content-between align-items-center flex-wrap"> |
| <span class="brand-title">GAP</span> |
| <nav id="main-nav" class="d-flex align-items-center flex-wrap"> |
| <a href="{{ request.app.url_path_for('manage_context_page') }}" class="spa-link">上下文管理</a> |
| <span id="admin-nav-links-placeholder" class="d-flex"> |
| {# 管理员链接将由JS动态插入 #} |
| </span> |
| <a href="{{ request.app.url_path_for('swagger_ui_html') }}" class="ms-md-3" data-no-spa>API文档(Swagger)</a> |
| <a href="{{ request.app.url_path_for('redoc_html') }}" class="ms-md-3" data-no-spa>API文档(ReDoc)</a> |
| <a href="#" id="logout-button" class="ms-md-3" data-no-spa>登出</a> |
| </nav> |
| </div> |
| </header> |
|
|
| <main class="container py-4" id="main-content-area"> |
| {% block content %}{% endblock %} |
| </main> |
|
|
| <footer class="mt-auto"> |
| <p style="display: flex; align-items: center; justify-content: center;"> |
| <span>© Gemini API Proxy.</span> |
| <a href="https://github.com/MisonL/GAP/" target="_blank" rel="noopener noreferrer" data-no-spa style="display: inline-flex; align-items: center; text-decoration: none; margin-left: 0.5rem;"> |
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-github" viewBox="0 0 16 16" style="margin-right: 0.25rem;"> |
| <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> |
| </svg>GitHub |
| </a> |
| </p> |
| </footer> |
|
|
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> |
| <script> |
| |
| const logoutButton = document.getElementById('logout-button'); |
| if (logoutButton) { |
| logoutButton.addEventListener('click', function(event) { |
| event.preventDefault(); |
| localStorage.removeItem('access_token'); |
| window.location.href = window.APP_ROUTES.root_get || "/"; |
| }); |
| } |
| |
| |
| function updateActiveNavLink(currentPath) { |
| document.querySelectorAll('#main-nav a.spa-link').forEach(link => { |
| link.classList.remove('active'); |
| try { |
| const linkPath = new URL(link.href, window.location.origin).pathname; |
| const targetPath = new URL(currentPath, window.location.origin).pathname; |
| if (linkPath === targetPath) { |
| link.classList.add('active'); |
| } |
| } catch (e) { |
| console.error("Error processing link for active state:", link.href, e); |
| } |
| }); |
| |
| document.querySelectorAll('#admin-nav-links-placeholder a.spa-link').forEach(link => { |
| link.classList.remove('active'); |
| try { |
| const linkPath = new URL(link.href, window.location.origin).pathname; |
| const targetPath = new URL(currentPath, window.location.origin).pathname; |
| if (linkPath === targetPath) { |
| link.classList.add('active'); |
| } |
| } catch (e) { |
| console.error("Error processing admin link for active state:", link.href, e); |
| } |
| }); |
| } |
| |
| async function fetchWithAuth(url, options = {}) { |
| let pureUrl = url; |
| try { |
| const incomingUrl = new URL(url, window.location.origin); |
| if (incomingUrl.origin === window.location.origin) { |
| const relativePart = incomingUrl.pathname + incomingUrl.search + incomingUrl.hash; |
| if (url !== relativePart) { |
| console.log(`fetchWithAuth: Original url "${url}" was an absolute same-origin URL, purified to "${relativePart}" for internal processing.`); |
| pureUrl = relativePart; |
| } |
| } |
| |
| } catch (e) { |
| |
| |
| console.warn(`fetchWithAuth: Could not parse url "${url}" as a full URL with origin, assuming it's a relative path or name. Error: ${e.message}`); |
| |
| } |
| |
| const token = localStorage.getItem('access_token'); |
| console.log('fetchWithAuth called for original URL:', url, ' (processed as pureUrl:', pureUrl, ') with token:', token ? token.substring(0,10)+'...' : 'null'); |
| |
| let processedUrl = pureUrl; |
| |
| if (pureUrl.startsWith('/') && !pureUrl.startsWith('//')) { |
| processedUrl = window.location.origin + pureUrl; |
| console.log('fetchWithAuth: Relative pureUrl "', pureUrl, '" converted to absolute:', processedUrl); |
| } else if (!pureUrl.startsWith('http://') && !pureUrl.startsWith('https://') && !pureUrl.startsWith('//')) { |
| |
| |
| if (!pureUrl.includes(':')) { |
| processedUrl = window.location.origin + '/' + pureUrl; |
| console.log('fetchWithAuth: Non-standard relative pureUrl "', pureUrl, '" converted to absolute:', processedUrl); |
| } else { |
| |
| |
| processedUrl = pureUrl; |
| } |
| } |
| |
| |
| |
| if (processedUrl.startsWith('http://') && window.location.protocol === 'https:') { |
| const originalForLog = processedUrl; |
| processedUrl = processedUrl.replace('http://', 'https://'); |
| console.log('fetchWithAuth: Mixed content for URL "', originalForLog, '", changed to HTTPS:', processedUrl); |
| } |
| |
| const headers = { ...(options.headers || {}), 'X-SPA-NAV': 'true' }; |
| if (token) { |
| headers['Authorization'] = `Bearer ${token}`; |
| } |
| const fetchOptions = { ...options, headers: headers }; |
| |
| try { |
| const response = await fetch(processedUrl, fetchOptions); |
| if (response.status === 401) { |
| console.warn('认证失败 (401),清除token并重定向。'); |
| localStorage.removeItem('access_token'); |
| window.location.href = window.APP_ROUTES.root_get || "/"; |
| throw new Error('Unauthorized'); |
| } |
| return response; |
| } catch (error) { |
| console.error('fetchWithAuth 错误:', error); |
| if (error.message !== 'Unauthorized') throw error; |
| return new Promise(() => {}); |
| } |
| } |
| |
| |
| async function loadPageContent(path, pushState = true) { |
| let pureRelativePathInput = path; |
| try { |
| const incomingUrl = new URL(path, window.location.origin); |
| if (incomingUrl.origin === window.location.origin) { |
| const relativePart = incomingUrl.pathname + incomingUrl.search + incomingUrl.hash; |
| if (path !== relativePart) { |
| console.log(`loadPageContent: Original path "${path}" was an absolute same-origin URL, purified to "${relativePart}" for further processing and history state.`); |
| pureRelativePathInput = relativePart; |
| } |
| } |
| |
| } catch (e) { |
| console.warn(`loadPageContent: Could not parse path "${path}" as a full URL with origin, assuming it's a relative path or name. Error: ${e.message}`); |
| |
| } |
| |
| const mainContentArea = document.getElementById('main-content-area'); |
| if (!mainContentArea) { console.error('#main-content-area 未找到!'); return; } |
| mainContentArea.innerHTML = '<p>加载中...</p>'; |
| |
| try { |
| |
| |
| const response = await fetchWithAuth(pureRelativePathInput); |
| if (!response.ok) { |
| mainContentArea.innerHTML = `<div class="alert alert-danger">加载失败: ${response.status} <pre>${await response.text()}</pre></div>`; |
| return; |
| } |
| mainContentArea.innerHTML = await response.text(); |
| |
| const newTitleTag = mainContentArea.querySelector('title'); |
| const pageTitlePart = (newTitleTag && newTitleTag.textContent) ? |
| newTitleTag.textContent.trim() : |
| (pureRelativePathInput.substring(pureRelativePathInput.lastIndexOf('/') + 1).replace(/_/g, ' ').replace(/\..+$/, '') || "管理首页"); |
| document.title = `${pageTitlePart} - 管理界面`; |
| if (newTitleTag) newTitleTag.remove(); |
| |
| |
| |
| if (pureRelativePathInput !== (window.location.pathname + window.location.search + window.location.hash)) { |
| let historyDisplayUrl = pureRelativePathInput; |
| |
| if (pureRelativePathInput.startsWith('/') && !pureRelativePathInput.startsWith('//')) { |
| historyDisplayUrl = window.location.origin + pureRelativePathInput; |
| } else if (!pureRelativePathInput.startsWith('http://') && !pureRelativePathInput.startsWith('https://') && !pureRelativePathInput.startsWith('//')) { |
| if (!pureRelativePathInput.includes(':')) { |
| historyDisplayUrl = window.location.origin + '/' + pureRelativePathInput; |
| } else { |
| historyDisplayUrl = pureRelativePathInput; |
| } |
| } |
| |
| if (historyDisplayUrl.startsWith('http://') && window.location.protocol === 'https:') { |
| historyDisplayUrl = historyDisplayUrl.replace('http://', 'https://'); |
| } |
| |
| history[pushState ? 'pushState' : 'replaceState']({ path: pureRelativePathInput }, document.title, historyDisplayUrl); |
| } |
| updateActiveNavLink(pureRelativePathInput); |
| |
| |
| const scripts = mainContentArea.querySelectorAll('script'); |
| scripts.forEach(oldScript => { |
| const newScript = document.createElement('script'); |
| |
| Array.from(oldScript.attributes).forEach(attr => { |
| newScript.setAttribute(attr.name, attr.value); |
| }); |
| if (oldScript.src) { |
| newScript.src = oldScript.src; |
| newScript.onload = () => console.log(`SPA: 外部脚本 ${oldScript.src} 已加载并执行。`); |
| newScript.onerror = () => console.error(`SPA: 外部脚本 ${oldScript.src} 加载失败。`); |
| } else { |
| newScript.textContent = oldScript.textContent; |
| } |
| |
| document.head.appendChild(newScript).parentNode.removeChild(newScript); |
| |
| }); |
| console.log(`SPA: 处理了 ${scripts.length} 个新加载的脚本。`); |
| |
| } catch (error) { |
| console.error('loadPageContent 错误:', error); |
| if (mainContentArea && error.message !== 'Unauthorized') { |
| mainContentArea.innerHTML = `<div class="alert alert-danger">脚本错误: ${error.message}</div>`; |
| } |
| } |
| } |
| |
| |
| function handleNavLinkClick(event) { |
| console.log('DEBUG: handleNavLinkClick - Event triggered. Target:', event.target); |
| |
| const link = event.target.closest('a:not([data-no-spa])'); |
| console.log('DEBUG: handleNavLinkClick - event.target.closest result (link):', link); |
| |
| if (!link || !link.href) { |
| console.log('DEBUG: handleNavLinkClick - No valid link or href found, exiting.'); |
| return; |
| } |
| |
| console.log('DEBUG: handleNavLinkClick - Link found:', link.href, 'ID:', link.id, 'ClassName:', link.className); |
| |
| let targetPath; |
| let isInternalSPAEligible = false; |
| |
| try { |
| const parsedUrl = new URL(link.href, window.location.origin); |
| |
| if (parsedUrl.origin === window.location.origin) { |
| isInternalSPAEligible = true; |
| targetPath = parsedUrl.pathname + parsedUrl.search + parsedUrl.hash; |
| console.log('DEBUG: handleNavLinkClick - Parsed as internal link. Relative path:', targetPath); |
| } else { |
| |
| console.log('DEBUG: handleNavLinkClick - Parsed as external link, letting browser handle:', parsedUrl.href); |
| return; |
| } |
| } catch (e) { |
| |
| |
| |
| console.warn('DEBUG: handleNavLinkClick - Error parsing link.href. Href:', link.href, 'Error:', e.message, 'Letting browser handle default.'); |
| return; |
| } |
| |
| |
| if (link.id === 'logout-button') { |
| console.log('DEBUG: handleNavLinkClick - Logout button clicked, not handling via SPA.'); |
| return; |
| } |
| |
| if (!isInternalSPAEligible) { |
| console.log('DEBUG: handleNavLinkClick - Not an internal SPA eligible link, letting browser handle default.'); |
| return; |
| } |
| |
| event.preventDefault(); |
| console.log('DEBUG: handleNavLinkClick - Default action prevented for SPA navigation.'); |
| console.log('DEBUG: handleNavLinkClick - SPA navigation to path:', targetPath); |
| |
| |
| const rootGetPath = (window.APP_ROUTES && window.APP_ROUTES.root_get) ? window.APP_ROUTES.root_get : "/"; |
| if (targetPath === (window.location.pathname + window.location.search + window.location.hash) && targetPath !== rootGetPath) { |
| console.log('DEBUG: handleNavLinkClick - Target path is current path and not root, not reloading.'); |
| return; |
| } |
| |
| console.log('DEBUG: handleNavLinkClick - Calling loadPageContent for path:', targetPath); |
| loadPageContent(targetPath, true); |
| } |
| |
| |
| |
| async function updateAdminNavAndInitialLoad() { |
| const placeholder = document.getElementById('admin-nav-links-placeholder'); |
| const token = localStorage.getItem('access_token'); |
| |
| if (placeholder) { |
| placeholder.innerHTML = ''; |
| if (token) { |
| try { |
| const contextDataUrl = window.APP_ROUTES.get_manage_context_data || "/manage/context/data"; |
| const response = await fetchWithAuth(contextDataUrl); |
| if (response.ok) { |
| const data = await response.json(); |
| if (data.is_admin) { |
| console.log('用户是管理员,添加管理员链接。'); |
| const reportLink = document.createElement('a'); |
| reportLink.href = window.APP_ROUTES.manage_report_page || "/manage/report"; |
| reportLink.className = 'text-white ms-md-3 spa-link'; |
| reportLink.textContent = '周期报告'; |
| |
| const keysLink = document.createElement('a'); |
| keysLink.href = window.APP_ROUTES.manage_keys_page || "/manage/keys"; |
| keysLink.className = 'text-white ms-md-3 spa-link'; |
| keysLink.textContent = '代理Key管理'; |
| |
| placeholder.appendChild(reportLink); |
| placeholder.appendChild(keysLink); |
| } else { |
| console.log('用户不是管理员,不添加管理员链接。'); |
| } |
| } else { |
| console.warn('获取管理员状态失败,状态:', response.status); |
| } |
| } catch (e) { |
| if (e.message !== 'Unauthorized') { |
| console.error("更新管理员导航时出错:", e); |
| } |
| } |
| } else { |
| console.log('未找到访问令牌,不添加管理员链接。'); |
| } |
| } |
| |
| const currentPath = window.location.pathname + window.location.search + window.location.hash; |
| console.log('SPA Initial Load: currentPath="', currentPath, '", token="', token ? token.substring(0,10)+'...' : 'null', '"'); |
| |
| if (token) { |
| let initialLoadPath = currentPath; |
| const rootPath = window.APP_ROUTES.root_get || "/"; |
| const manageContextPath = window.APP_ROUTES.manage_context_page || "/manage/context"; |
| |
| if (currentPath === rootPath || currentPath === "/") { |
| console.log('已登录且在根路径,自动加载默认管理页面。'); |
| initialLoadPath = manageContextPath; |
| loadPageContent(initialLoadPath, false); |
| } else if (currentPath.startsWith('/manage/')) { |
| console.log('直接访问管理页面,内容已由服务器渲染,但仍需确保JS初始化和激活链接。'); |
| |
| loadPageContent(initialLoadPath, false); |
| } |
| |
| |
| setTimeout(() => updateActiveNavLink(initialLoadPath || window.location.pathname), 0); |
| } else { |
| updateActiveNavLink(currentPath); |
| } |
| } |
| |
| |
| window.addEventListener('popstate', async (event) => { |
| console.log('popstate 事件触发:', event.state); |
| const tokenPop = localStorage.getItem('access_token'); |
| const placeholderPop = document.getElementById('admin-nav-links-placeholder'); |
| if (placeholderPop) { |
| placeholderPop.innerHTML = ''; |
| if (tokenPop) { |
| try { |
| const contextDataUrlPop = window.APP_ROUTES.get_manage_context_data || "/manage/context/data"; |
| const response = await fetchWithAuth(contextDataUrlPop); |
| if (response.ok) { |
| const data = await response.json(); |
| if (data.is_admin) { |
| const reportLinkPop = document.createElement('a'); |
| reportLinkPop.href = window.APP_ROUTES.manage_report_page || "/manage/report"; |
| reportLinkPop.className = 'text-white ms-md-3 spa-link'; |
| reportLinkPop.textContent = '周期报告'; |
| placeholderPop.appendChild(reportLinkPop); |
| |
| const keysLinkPop = document.createElement('a'); |
| keysLinkPop.href = window.APP_ROUTES.manage_keys_page || "/manage/keys"; |
| keysLinkPop.className = 'text-white ms-md-3 spa-link'; |
| keysLinkPop.textContent = '代理Key管理'; |
| placeholderPop.appendChild(keysLinkPop); |
| } |
| } |
| } catch(e) { if (e.message !== 'Unauthorized') console.error("Popstate: 更新导航栏时出错:", e); } |
| } |
| } |
| |
| const targetPath = (event.state && event.state.path) ? event.state.path : (window.location.pathname + window.location.search + window.location.hash); |
| console.log('Popstate: 目标路径为', targetPath); |
| loadPageContent(targetPath, false); |
| }); |
| |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| console.log('DOMContentLoaded: 开始设置。'); |
| updateAdminNavAndInitialLoad(); |
| |
| document.body.addEventListener('click', handleNavLinkClick); |
| console.log('DOMContentLoaded: SPA click handler attached to body.'); |
| }); |
| </script> |
| {% block scripts %}{% endblock %} |
| </body> |
| </html> |
|
|