| {% if not is_spa_request %} |
| {% extends "_base.html" %} |
| {% endif %} |
|
|
| {% block content %} |
| {# 将所有内容包裹在 page-content-wrapper 中,以便SPA提取 #} |
| <div id="page-content-wrapper"> |
| <div class="container-lg mt-4 px-lg-5 px-md-4 px-3"> |
| <div class="d-flex justify-content-between align-items-center mb-4"> |
| <h1>API 使用情况报告</h1> |
| <small class="text-muted">最后更新时间: <span id="last-updated-time">加载中...</span></small> |
| </div> |
|
|
| <div class="row"> |
| |
| <div class="col-lg-6"> |
| |
| <div class="card shadow-sm mb-4"> |
| <div class="card-header bg-light"> |
| <h5 class="mb-0">容量与使用情况</h5> |
| </div> |
| <div class="card-body"> |
| <div class="row align-items-center"> |
| |
| <div class="col-md-6 text-center mb-3 mb-md-0"> |
| <h6>RPD (每日请求)</h6> |
| <div class="chart-container mx-auto" style="position: relative;"> |
| <canvas id="rpd-chart"></canvas> |
| <div id="rpd-percentage" class="chart-percentage">--%</div> |
| </div> |
| <p id="rpd-stats" class="mt-2 small text-muted">加载中...</p> |
| </div> |
| |
| <div class="col-md-6 text-center mb-3 mb-md-0"> |
| <h6>TPD 输入 (每日 Token)</h6> |
| <div class="chart-container mx-auto" style="position: relative;"> |
| <canvas id="tpd-input-chart"></canvas> |
| <div id="tpd-input-percentage" class="chart-percentage">--%</div> |
| </div> |
| <p id="tpd-input-stats" class="mt-2 small text-muted">加载中...</p> |
| </div> |
| |
| <div class="col-12 mt-4"> |
| <h6>关键指标</h6> |
| <ul class="list-group list-group-flush"> |
| <li class="list-group-item d-flex justify-content-between align-items-center"> |
| 活跃 Key 数量: |
| <span id="active-keys-count" class="badge bg-primary rounded-pill">...</span> |
| </li> |
| <li class="list-group-item d-flex justify-content-between align-items-center"> |
| 启动时无效 Key: |
| <span id="invalid-keys-startup" class="badge bg-warning text-dark rounded-pill">...</span> |
| </li> |
| <li class="list-group-item d-flex justify-content-between align-items-center"> |
| 过去 7 天平均日 RPD: |
| <span id="avg-daily-rpd" class="badge bg-info rounded-pill">...</span> |
| </li> |
| |
| <li id="rpd-capacity-list-container" class="list-group-item"> |
| RPD 容量估算: |
| {% if has_valid_keys %} |
| <div class="small text-muted mt-1">加载中...</div> |
| {% else %} |
| <div class="small mt-1">无有效 API Key,无法估算容量</div> {# 无有效 Key 时的提示 #} |
| {% endif %} |
| </li> |
| |
| <li id="tpd-input-capacity-list-container" class="list-group-item"> |
| TPD 输入容量估算: |
| {% if has_valid_keys %} |
| <div class="small text-muted mt-1">加载中...</div> |
| {% else %} |
| <div class="small mt-1">无有效 API Key,无法估算容量</div> {# 无有效 Key 时的提示 #} |
| {% endif %} |
| </li> |
| </ul> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="card shadow-sm mb-4"> |
| <div class="card-header bg-light"> |
| <h5 class="mb-0">Key 使用概览</h5> |
| </div> |
| <div class="card-body"> |
| <div id="key-status-charts" class="row justify-content-center"> |
| |
| <p id="key-status-loading" class="text-center text-muted">正在加载 Key 状态图表...</p> |
| </div> |
| <hr> |
| <h6>状态详情</h6> |
| <div id="key-status-details" class="mt-3 small"> |
| |
| <p class="text-center text-muted">暂无详情数据。</p> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="col-lg-6"> |
| |
| <div class="card shadow-sm mb-4"> |
| <div class="card-header bg-light"> |
| <h5 class="mb-0">Key 数量建议</h5> |
| </div> |
| <div class="card-body"> |
| <p id="key-suggestion" class="lead">正在加载建议...</p> |
| </div> |
| </div> |
|
|
| |
| <div class="card shadow-sm mb-4"> |
| <div class="card-header bg-light"> |
| <h5 class="mb-0">Top 5 IP 地址统计 (PT 时间)</h5> |
| </div> |
| <div class="card-body"> |
| <div class="row"> |
| <div class="col-12 mb-4"> |
| <h6>请求次数</h6> |
| <div class="table-responsive"> |
| <table class="table table-sm table-striped table-hover"> |
| <thead class="table-light"> |
| <tr><th>时间范围</th><th>IP 地址</th><th>次数</th></tr> |
| </thead> |
| <tbody id="top-ips-requests"> |
| <tr><td colspan="3" class="text-center text-muted">正在加载...</td></tr> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| <div class="col-12"> |
| <h6>输入 Token</h6> |
| <div class="table-responsive"> |
| <table class="table table-sm table-striped table-hover"> |
| <thead class="table-light"> |
| <tr><th>时间范围</th><th>IP 地址</th><th>Token 数</th></tr> |
| </thead> |
| <tbody id="top-ips-tokens"> |
| <tr><td colspan="3" class="text-center text-muted">正在加载...</td></tr> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="card shadow-sm mb-4"> |
| <div class="card-header bg-light"> |
| <h5 class="mb-0">Key 筛选原因统计</h5> |
| </div> |
| <div class="card-body"> |
| <h6>按原因汇总</h6> |
| <ul id="key-selection-reasons-summary" class="list-group list-group-flush mb-4"> |
| <li class="list-group-item text-center text-muted">正在加载...</li> |
| </ul> |
|
|
| <h6>按 Key 详细统计</h6> |
| <div class="table-responsive"> |
| <table class="table table-sm table-striped table-hover"> |
| <thead class="table-light"> |
| <tr> |
| <th>Key ID (部分)</th> |
| <th>筛选次数</th> |
| <th>主要原因</th> |
| </tr> |
| </thead> |
| <tbody id="key-selection-details"> |
| <tr><td colspan="3" class="text-center text-muted">正在加载...</td></tr> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <style> |
| |
| |
| .chart-container { |
| position: relative; |
| width: 100%; |
| max-width: 200px; |
| margin: auto; |
| |
| |
| filter: drop-shadow(0px 8px 15px rgba(0, 0, 0, 0.2)) drop-shadow(0px 3px 6px rgba(0, 0, 0, 0.15)); |
| } |
| |
| |
| .chart-percentage { |
| position: absolute; |
| top: 55%; |
| left: 50%; |
| |
| |
| transform: translate(-50%, -50%); |
| font-size: 1.2rem; |
| font-weight: bold; |
| color: #333; |
| text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); |
| pointer-events: none; |
| } |
| |
| #key-status-charts .col-lg-4, |
| #key-status-charts .col-md-6, |
| #key-status-charts .col-sm-6 { |
| |
| max-width: 200px; |
| margin-left: auto; |
| margin-right: auto; |
| } |
| #key-status-charts canvas { |
| max-height: 150px; |
| } |
| |
| |
| @media (min-width: 992px) { |
| .row .col-lg-6 { |
| flex: 0 0 auto; |
| width: 50% !important; |
| } |
| } |
| |
| #active-keys-count, |
| #invalid-keys-startup, |
| #avg-daily-rpd { |
| box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); |
| } |
| </style> |
|
|
| <script> |
| |
| async function initializeManageReportPage() { |
| console.log('Report Page: initializeManageReportPage() called.'); |
| |
| const token = localStorage.getItem('access_token'); |
| if (!token) { |
| console.log('Report Page: No token found, redirecting to login.'); |
| window.location.href = '/'; |
| return; |
| } else { |
| console.log('Report Page: Token found.'); |
| } |
| |
| let rpdChartInstance = null; |
| let tpdInputChartInstance = null; |
| let keyStatusChartInstances = {}; |
| |
| function formatNumber(num) { |
| if (num === null || num === undefined || isNaN(num)) { |
| return 'N/A'; |
| } |
| const numberValue = parseFloat(num); |
| if (isNaN(numberValue)) { |
| return 'N/A'; |
| } |
| return numberValue.toLocaleString(); |
| } |
| |
| function renderKeyStatusCharts(keyUsageSummaryData) { |
| const chartsContainer = document.getElementById('key-status-charts'); |
| const detailsContainer = document.getElementById('key-status-details'); |
| if (chartsContainer) chartsContainer.innerHTML = ''; |
| if (detailsContainer) detailsContainer.innerHTML = ''; |
| keyStatusChartInstances = {}; |
| |
| const modelsData = keyUsageSummaryData?.models; |
| if (!modelsData || !Array.isArray(modelsData) || modelsData.length === 0) { |
| if (chartsContainer) chartsContainer.innerHTML = '<p class="text-center text-muted col-12">暂无 Key 使用数据可供可视化。</p>'; |
| if (detailsContainer) detailsContainer.innerHTML = '<p class="text-center text-muted">暂无详情数据。</p>'; |
| return; |
| } |
| |
| modelsData.forEach(modelSummary => { |
| const modelName = modelSummary.model_name; |
| const totalKeys = modelSummary.key_count; |
| const statusDistribution = modelSummary.status_distribution; |
| |
| if (!modelName || !statusDistribution || !Array.isArray(statusDistribution) || statusDistribution.length === 0) { |
| console.warn(`Skipping rendering chart for model with incomplete data: ${modelName}`, modelSummary); |
| return; |
| } |
| |
| const chartCol = document.createElement('div'); |
| chartCol.className = 'col-lg-4 col-md-6 col-sm-6 mb-4 text-center'; |
| const chartTitle = document.createElement('h6'); |
| chartTitle.textContent = `${modelName} (${formatNumber(totalKeys)} Keys)`; |
| const canvas = document.createElement('canvas'); |
| canvas.id = `key-status-chart-${modelName}`; |
| canvas.style.margin = '0 auto'; |
| chartCol.appendChild(chartTitle); |
| chartCol.appendChild(canvas); |
| if (chartsContainer) chartsContainer.appendChild(chartCol); |
| |
| const labels = statusDistribution.map(item => item.status); |
| const dataCounts = statusDistribution.map(item => item.count); |
| const backgroundColors = [ |
| 'rgba(40, 167, 69, 0.7)', |
| 'rgba(255, 193, 7, 0.7)', |
| 'rgba(220, 53, 69, 0.7)', |
| 'rgba(0, 123, 255, 0.7)', |
| 'rgba(108, 117, 125, 0.7)', |
| 'rgba(111, 66, 193, 0.7)' |
| ]; |
| const borderColors = backgroundColors.map(color => color.replace('0.7', '1')); |
| |
| const ctx = canvas.getContext('2d'); |
| keyStatusChartInstances[modelName] = new Chart(ctx, { |
| type: 'pie', |
| data: { |
| labels: labels.map(label => label.length > 20 ? `${label.substring(0, 18)}...` : label), |
| datasets: [{ |
| label: 'Key 数量', |
| data: dataCounts, |
| backgroundColor: backgroundColors.slice(0, dataCounts.length), |
| borderColor: borderColors.slice(0, dataCounts.length), |
| borderWidth: 1 |
| }] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| plugins: { |
| legend: { |
| position: 'bottom', |
| labels: { |
| boxWidth: 10, |
| padding: 15, |
| font: { |
| size: 10 |
| } |
| } |
| }, |
| tooltip: { |
| callbacks: { |
| label: function(context) { |
| let originalLabel = labels[context.dataIndex] || ''; |
| let value = context.parsed || 0; |
| return `${originalLabel}: ${formatNumber(value)}`; |
| } |
| } |
| } |
| } |
| } |
| }); |
| |
| const detailDiv = document.createElement('div'); |
| detailDiv.className = 'mb-3 border-start ps-3'; |
| let detailHtml = `<h6>${modelName}</h6><ul>`; |
| statusDistribution.forEach(item => { |
| detailHtml += `<li>${item.status}: ${formatNumber(item.count)}</li>`; |
| }); |
| detailHtml += `</ul>`; |
| detailDiv.innerHTML = detailHtml; |
| if (detailsContainer) detailsContainer.appendChild(detailDiv); |
| }); |
| if (Object.keys(keyStatusChartInstances).length === 0) { |
| if (chartsContainer) chartsContainer.innerHTML = '<p class="text-center text-muted col-12">暂无 Key 使用数据可供可视化。</p>'; |
| if (detailsContainer) detailsContainer.innerHTML = '<p class="text-center text-muted">暂无详情数据。</p>'; |
| } |
| } |
| |
| function renderCapacityCharts(rpdData, tpdInputData) { |
| const rpdCanvas = document.getElementById('rpd-chart'); |
| const tpdInputCanvas = document.getElementById('tpd-input-chart'); |
| const rpdPercentageDiv = document.getElementById('rpd-percentage'); |
| const tpdInputPercentageDiv = document.getElementById('tpd-input-percentage'); |
| const rpdStatsElem = document.getElementById('rpd-stats'); |
| const tpdInputStatsElem = document.getElementById('tpd-input-stats'); |
| |
| if (rpdChartInstance) rpdChartInstance.destroy(); |
| if (tpdInputChartInstance) tpdInputChartInstance.destroy(); |
| |
| const rpdCapacity = rpdData?.capacity || 0; |
| const rpdUsedToday = rpdData?.used_today || 0; |
| const rpdEstimatedToday = rpdData?.estimated_today; |
| |
| if (rpdCanvas) { |
| const rpdCtx = rpdCanvas.getContext('2d'); |
| const rpdUsedValue = (typeof rpdEstimatedToday === 'number' && rpdEstimatedToday > 0) ? rpdEstimatedToday : rpdUsedToday; |
| const rpdUsageRatio = (rpdCapacity > 0) ? (rpdUsedValue / rpdCapacity) * 100 : 0; |
| const rpdRemainingRatio = Math.max(0, 100 - rpdUsageRatio); |
| rpdChartInstance = new Chart(rpdCtx, { |
| type: 'doughnut', |
| data: { |
| labels: ['已用/预估', '剩余'], |
| datasets: [{ |
| data: [rpdUsageRatio, rpdRemainingRatio], |
| backgroundColor: ['rgba(220, 53, 69, 0.7)', 'rgba(76, 175, 80, 0.8)'], |
| borderColor: ['rgba(220, 53, 69, 1)', 'rgba(76, 175, 80, 1)'], |
| borderWidth: 1, |
| circumference: 180, |
| rotation: -90, |
| }] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| plugins: { |
| tooltip: { |
| callbacks: { |
| label: function(context) { |
| return `${context.label}: ${context.parsed.toFixed(1)}%`; |
| } |
| } |
| }, |
| legend: { display: false } |
| }, |
| cutout: '70%' |
| } |
| }); |
| if (rpdPercentageDiv) rpdPercentageDiv.textContent = `${rpdUsageRatio.toFixed(1)}%`; |
| if (rpdStatsElem) rpdStatsElem.textContent = `已用: ${formatNumber(rpdUsedToday)}, 预估: ${formatNumber(rpdEstimatedToday) || 'N/A'} / 容量: ${formatNumber(rpdCapacity)}`; |
| } else { |
| if (rpdStatsElem) rpdStatsElem.textContent = `已用: ${formatNumber(rpdUsedToday)} / 容量: ${formatNumber(rpdCapacity) || 'N/A'}`; |
| if (rpdPercentageDiv) rpdPercentageDiv.textContent = '--%'; |
| } |
| |
| const tpdInputCapacity = tpdInputData?.tpd_input_capacity || 0; |
| const tpdInputUsedToday = tpdInputData?.tpd_input_used_today || 0; |
| const tpdInputEstimatedToday = tpdInputData?.tpd_input_estimated_today; |
| |
| if (tpdInputCanvas) { |
| const tpdInputCtx = tpdInputCanvas.getContext('2d'); |
| const tpdInputUsedValue = (typeof tpdInputEstimatedToday === 'number' && tpdInputEstimatedToday > 0) ? tpdInputEstimatedToday : tpdInputUsedToday; |
| const tpdInputUsageRatio = (tpdInputCapacity > 0) ? (tpdInputUsedValue / tpdInputCapacity) * 100 : 0; |
| const tpdInputRemainingRatio = Math.max(0, 100 - tpdInputUsageRatio); |
| tpdInputChartInstance = new Chart(tpdInputCtx, { |
| type: 'doughnut', |
| data: { |
| labels: ['已用/预估', '剩余'], |
| datasets: [{ |
| data: [tpdInputUsageRatio, tpdInputRemainingRatio], |
| backgroundColor: ['rgba(220, 53, 69, 0.7)', 'rgba(76, 175, 80, 0.8)'], |
| borderColor: ['rgba(220, 53, 69, 1)', 'rgba(76, 175, 80, 1)'], |
| borderWidth: 1, |
| circumference: 180, |
| rotation: -90, |
| }] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| plugins: { |
| tooltip: { |
| callbacks: { |
| label: function(context) { |
| return `${context.label}: ${context.parsed.toFixed(1)}%`; |
| } |
| } |
| }, |
| legend: { display: false } |
| }, |
| cutout: '70%' |
| } |
| }); |
| if (tpdInputPercentageDiv) tpdInputPercentageDiv.textContent = `${tpdInputUsageRatio.toFixed(1)}%`; |
| if (tpdInputStatsElem) tpdInputStatsElem.textContent = `已用: ${formatNumber(tpdInputUsedToday)}, 预估: ${formatNumber(tpdInputEstimatedToday) || 'N/A'} / 容量: ${formatNumber(tpdInputCapacity)}`; |
| } else { |
| if (tpdInputStatsElem) tpdInputStatsElem.textContent = `已用: ${formatNumber(tpdInputUsedToday)} / 容量: ${formatNumber(tpdInputCapacity) || 'N/A'}`; |
| if (tpdInputPercentageDiv) tpdInputPercentageDiv.textContent = '--%'; |
| } |
| } |
| |
| function renderKeySuggestion(suggestionData) { |
| const suggestionElem = document.getElementById('key-suggestion'); |
| if (suggestionElem) { |
| if (suggestionData && suggestionData.suggestion) { |
| suggestionElem.textContent = suggestionData.suggestion; |
| } else { |
| suggestionElem.textContent = '暂无 Key 数量建议。'; |
| } |
| }; |
| } |
| |
| function renderTopIps(topIPsData) { |
| const requestsTbody = document.getElementById('top-ips-requests'); |
| const tokensTbody = document.getElementById('top-ips-tokens'); |
| |
| if (requestsTbody) requestsTbody.innerHTML = ''; |
| if (tokensTbody) tokensTbody.innerHTML = ''; |
| |
| if (topIPsData && topIPsData.requests && topIPsData.requests.length > 0) { |
| topIPsData.requests.forEach(item => { |
| const row = requestsTbody.insertRow(); |
| row.insertCell().textContent = item.time_range; |
| row.insertCell().textContent = item.ip_address; |
| row.insertCell().textContent = formatNumber(item.request_count); |
| }); |
| } else { |
| const row = requestsTbody.insertRow(); |
| const cell = row.insertCell(); |
| cell.colSpan = 3; |
| cell.className = 'text-center text-muted'; |
| cell.textContent = '暂无请求统计数据。'; |
| } |
| |
| if (topIPsData && topIPsData.tokens && topIPsData.tokens.length > 0) { |
| topIPsData.tokens.forEach(item => { |
| const row = tokensTbody.insertRow(); |
| row.insertCell().textContent = item.time_range; |
| row.insertCell().textContent = item.ip_address; |
| row.insertCell().textContent = formatNumber(item.token_count); |
| }); |
| } else { |
| const row = tokensTbody.insertRow(); |
| const cell = row.insertCell(); |
| cell.colSpan = 3; |
| cell.className = 'text-center text-muted'; |
| cell.textContent = '暂无 Token 统计数据。'; |
| } |
| } |
| |
| function renderKeySelectionStats(keySelectionStatsData) { |
| const reasonsSummaryList = document.getElementById('key-selection-reasons-summary'); |
| const detailsTableBody = document.getElementById('key-selection-details'); |
| |
| if (reasonsSummaryList) reasonsSummaryList.innerHTML = ''; |
| if (detailsTableBody) detailsTableBody.innerHTML = ''; |
| |
| if (!keySelectionStatsData || |
| (!keySelectionStatsData.total_by_reason || Object.keys(keySelectionStatsData.total_by_reason).length === 0) && |
| (!keySelectionStatsData.details_by_key || Object.keys(keySelectionStatsData.details_by_key).length === 0)) |
| { |
| if (reasonsSummaryList) reasonsSummaryList.innerHTML = '<li class="list-group-item text-center text-muted">暂无 Key 筛选原因统计数据。</li>'; |
| if (detailsTableBody) detailsTableBody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">暂无 Key 筛选详细统计数据。</td></tr>'; |
| return; |
| } |
| |
| const reasonsSummary = keySelectionStatsData.total_by_reason || {}; |
| if (Object.keys(reasonsSummary).length > 0) { |
| const sortedReasons = Object.entries(reasonsSummary).sort((a, b) => a[0].localeCompare(b[0])); |
| sortedReasons.forEach(([reason, count]) => { |
| const listItem = document.createElement('li'); |
| listItem.className = 'list-group-item d-flex justify-content-between align-items-center'; |
| listItem.textContent = `${reason}:`; |
| const badge = document.createElement('span'); |
| badge.className = 'badge bg-secondary rounded-pill'; |
| badge.textContent = formatNumber(count); |
| listItem.appendChild(badge); |
| if (reasonsSummaryList) reasonsSummaryList.appendChild(listItem); |
| }); |
| } else { |
| if (reasonsSummaryList) reasonsSummaryList.innerHTML = '<li class="list-group-item text-center text-muted">暂无按原因汇总数据。</li>'; |
| } |
| |
| const keyDetails = keySelectionStatsData.details_by_key || {}; |
| if (Object.keys(keyDetails).length > 0) { |
| const sortedKeyDetails = Object.entries(keyDetails).sort((a, b) => a[0].localeCompare(b[0])); |
| sortedKeyDetails.forEach(([keyId, reasonsCounter]) => { |
| let mainReason = '未知原因'; |
| let maxCount = 0; |
| let totalCountForKey = 0; |
| for (const reason in reasonsCounter) { |
| const count = reasonsCounter[reason]; |
| totalCountForKey += count; |
| if (count > maxCount) { |
| maxCount = count; |
| mainReason = reason; |
| } |
| } |
| |
| const row = detailsTableBody.insertRow(); |
| row.insertCell().textContent = keyId; |
| row.insertCell().textContent = formatNumber(totalCountForKey); |
| row.insertCell().textContent = mainReason; |
| }); |
| } else { |
| if (detailsTableBody) detailsTableBody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">暂无按 Key 详细统计数据。</td></tr>'; |
| } |
| } |
| |
| async function fetchReportData() { |
| document.getElementById('last-updated-time').textContent = '加载中...'; |
| try { |
| const response = await fetch('/api/manage/report/data', { |
| headers: { |
| 'Authorization': `Bearer ${token}` |
| } |
| }); |
| if (!response.ok) { |
| if (response.status === 401 || response.status === 403) { |
| console.error('认证失败或无权限,重定向到登录页。'); |
| window.location.href = '/'; |
| return; |
| } |
| throw new Error(`HTTP error! status: ${response.status}`); |
| } |
| const data = await response.json(); |
| console.log('报告数据获取成功:', data); |
| |
| const now = new Date(); |
| document.getElementById('last-updated-time').textContent = now.toLocaleString(); |
| renderCapacityCharts(data.overall_stats, data.overall_stats); |
| renderKeyStatusCharts(data.key_usage_summary); |
| renderKeySuggestion(data.key_suggestion); |
| renderTopIps(data.top_ips); |
| renderKeySelectionStats(data.key_selection_tracking); |
| |
| } catch (error) { |
| console.error('获取报告数据失败:', error); |
| document.getElementById('last-updated-time').textContent = '加载失败'; |
| document.getElementById('rpd-stats').textContent = '数据加载失败'; |
| document.getElementById('tpd-input-stats').textContent = '数据加载失败'; |
| document.getElementById('key-status-loading').textContent = '数据加载失败'; |
| document.getElementById('key-status-details').innerHTML = '<p class="text-center text-danger">数据加载失败。</p>'; |
| document.getElementById('key-suggestion').textContent = '数据加载失败。'; |
| document.getElementById('top-ips-requests').innerHTML = '<tr><td colspan="3" class="text-center text-danger">数据加载失败。</td></tr>'; |
| document.getElementById('top-ips-tokens').innerHTML = '<tr><td colspan="3" class="text-center text-danger">数据加载失败。</td></tr>'; |
| document.getElementById('key-selection-reasons-summary').innerHTML = '<li class="list-group-item text-center text-danger">数据加载失败。</li>'; |
| document.getElementById('key-selection-details').innerHTML = '<tr><td colspan="3" class="text-center text-danger">数据加载失败。</td></tr>'; |
| } |
| } |
| |
| |
| |
| if (typeof Chart !== 'undefined') { |
| Chart.register({ |
| id: 'centerText', |
| beforeDraw: function(chart) { |
| if (chart.config.options.cutout !== '70%' || chart.config.options.circumference !== 180) { |
| return; |
| } |
| const ctx = chart.ctx; |
| const width = chart.width; |
| const height = chart.height; |
| const centerX = width / 2; |
| const centerY = height * 0.7; |
| ctx.restore(); |
| const fontSize = (height / 114).toFixed(2); |
| ctx.font = `${fontSize}em sans-serif`; |
| ctx.textBaseline = "middle"; |
| const text = chart.config.options.plugins.centerText?.text || ''; |
| const textX = centerX; |
| const textY = centerY; |
| ctx.fillText(text, textX, textY); |
| ctx.save(); |
| } |
| }); |
| } else { |
| console.warn("Chart.js 未定义,无法注册 centerText 插件。"); |
| } |
| |
| fetchReportData(); |
| |
| } |
| |
| if (typeof initializeManageReportPage === 'function') { |
| console.log('Manage Report (content script): Calling initializeManageReportPage.'); |
| initializeManageReportPage(); |
| } |
| </script> |
| </div> |
| {% endblock %} |
|
|