feat: add cost period selection and update cost chart functionality

This commit is contained in:
Supra4E8C
2025-11-27 19:39:59 +08:00
parent fd1956cb94
commit ca14ab4917
3 changed files with 107 additions and 31 deletions

8
app.js
View File

@@ -503,6 +503,8 @@ class CLIProxyManager {
const requestsDayBtn = document.getElementById('requests-day-btn'); const requestsDayBtn = document.getElementById('requests-day-btn');
const tokensHourBtn = document.getElementById('tokens-hour-btn'); const tokensHourBtn = document.getElementById('tokens-hour-btn');
const tokensDayBtn = document.getElementById('tokens-day-btn'); const tokensDayBtn = document.getElementById('tokens-day-btn');
const costHourBtn = document.getElementById('cost-hour-btn');
const costDayBtn = document.getElementById('cost-day-btn');
const chartLineSelects = document.querySelectorAll('.chart-line-select'); const chartLineSelects = document.querySelectorAll('.chart-line-select');
const modelPriceForm = document.getElementById('model-price-form'); const modelPriceForm = document.getElementById('model-price-form');
const resetModelPricesBtn = document.getElementById('reset-model-prices'); const resetModelPricesBtn = document.getElementById('reset-model-prices');
@@ -523,6 +525,12 @@ class CLIProxyManager {
if (tokensDayBtn) { if (tokensDayBtn) {
tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day')); tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day'));
} }
if (costHourBtn) {
costHourBtn.addEventListener('click', () => this.switchCostPeriod('hour'));
}
if (costDayBtn) {
costDayBtn.addEventListener('click', () => this.switchCostPeriod('day'));
}
if (chartLineSelects.length) { if (chartLineSelects.length) {
chartLineSelects.forEach(select => { chartLineSelects.forEach(select => {
select.addEventListener('change', (event) => { select.addEventListener('change', (event) => {

View File

@@ -997,9 +997,17 @@
</div> </div>
<div class="card chart-card cost-chart-card"> <div class="card chart-card cost-chart-card">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-sack-dollar"></i> <span data-i18n="usage_stats.cost_trend">花费统计</span></h3> <h3><i class="fas fa-sack-dollar"></i> <span data-i18n="usage_stats.cost_trend">花费统计</span></h3>
<div class="chart-controls">
<button class="btn btn-small" data-period="hour" id="cost-hour-btn">
<span data-i18n="usage_stats.by_hour">按小时</span>
</button>
<button class="btn btn-small active" data-period="day" id="cost-day-btn">
<span data-i18n="usage_stats.by_day">按天</span>
</button>
</div> </div>
</div>
<div class="card-content"> <div class="card-content">
<div class="chart-container"> <div class="chart-container">
<canvas id="cost-chart"></canvas> <canvas id="cost-chart"></canvas>

View File

@@ -102,16 +102,18 @@ export async function loadUsageStats(usageData = null) {
// 读取当前图表周期 // 读取当前图表周期
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
const requestsPeriod = requestsHourActive ? 'hour' : 'day'; const requestsPeriod = requestsHourActive ? 'hour' : 'day';
const tokensPeriod = tokensHourActive ? 'hour' : 'day'; const tokensPeriod = tokensHourActive ? 'hour' : 'day';
const costPeriod = costHourActive ? 'hour' : 'day';
// 初始化图表(使用当前周期) // 初始化图表(使用当前周期)
this.initializeRequestsChart(requestsPeriod); this.initializeRequestsChart(requestsPeriod);
this.initializeTokensChart(tokensPeriod); this.initializeTokensChart(tokensPeriod);
this.updateCostSummaryAndChart(usage, costPeriod);
// 更新API详细统计表格 // 更新API详细统计表格
this.updateApiStatsTable(usage); this.updateApiStatsTable(usage);
this.updateCostSummaryAndChart(usage);
} catch (error) { } catch (error) {
console.error('加载使用统计失败:', error); console.error('加载使用统计失败:', error);
@@ -566,7 +568,7 @@ export function handleModelPriceSubmit() {
next[model] = { prompt, completion }; next[model] = { prompt, completion };
this.persistModelPrices(next); this.persistModelPrices(next);
this.renderSavedModelPrices(); this.renderSavedModelPrices();
this.updateCostSummaryAndChart(); this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod());
this.showNotification(i18n.t('usage_stats.model_price_saved'), 'success'); this.showNotification(i18n.t('usage_stats.model_price_saved'), 'success');
} }
@@ -583,7 +585,7 @@ export function handleModelPriceReset() {
} }
this.renderSavedModelPrices(); this.renderSavedModelPrices();
this.prefillModelPriceInputs(); this.prefillModelPriceInputs();
this.updateCostSummaryAndChart(); this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod());
} }
export function calculateTokenBreakdown(usage = null) { export function calculateTokenBreakdown(usage = null) {
@@ -825,7 +827,7 @@ export function formatUsd(value) {
return `$${parts}`; return `$${parts}`;
} }
export function calculateCostData(prices = null, usage = null) { export function calculateCostData(prices = null, usage = null, period = 'day') {
const priceTable = prices || this.modelPrices || {}; const priceTable = prices || this.modelPrices || {};
const usagePayload = usage || this.currentUsageData; const usagePayload = usage || this.currentUsageData;
const entries = Object.entries(priceTable || {}); const entries = Object.entries(priceTable || {});
@@ -840,24 +842,11 @@ export function calculateCostData(prices = null, usage = null) {
return result; return result;
} }
const labelSet = new Set(); const normalizedDetails = details.map(detail => {
const costByModelDay = new Map();
let totalCost = 0;
details.forEach(detail => {
const parsedTimestamp = Date.parse(detail.timestamp);
if (Number.isNaN(parsedTimestamp)) {
return;
}
const dayLabel = this.formatDayLabel(new Date(parsedTimestamp));
if (!dayLabel) {
return;
}
const modelName = detail.__modelName || 'Unknown'; const modelName = detail.__modelName || 'Unknown';
const price = priceTable[modelName]; const price = priceTable[modelName];
if (!price) { if (!price) {
return; return null;
} }
const tokens = detail?.tokens || {}; const tokens = detail?.tokens || {};
@@ -866,19 +855,66 @@ export function calculateCostData(prices = null, usage = null) {
const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0); const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0);
const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0); const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
const detailCost = promptCost + completionCost; const detailCost = promptCost + completionCost;
const parsedTimestamp = Date.parse(detail.timestamp);
if (!Number.isFinite(detailCost) || detailCost <= 0) { if (!Number.isFinite(detailCost) || detailCost <= 0 || Number.isNaN(parsedTimestamp)) {
return; return null;
} }
totalCost += detailCost; return { modelName, cost: detailCost, timestamp: parsedTimestamp };
labelSet.add(dayLabel); }).filter(Boolean);
if (!normalizedDetails.length) {
return result;
}
const totalCost = normalizedDetails.reduce((sum, item) => sum + item.cost, 0);
if (period === 'hour') {
const meta = this.createHourlyBucketMeta();
const dataByModel = new Map();
normalizedDetails.forEach(({ modelName, cost, timestamp }) => {
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
const bucketStart = normalized.getTime();
if (bucketStart < meta.earliestTime || bucketStart > meta.lastBucketTime) {
return;
}
const bucketIndex = Math.floor((bucketStart - meta.earliestTime) / meta.bucketSize);
if (bucketIndex < 0 || bucketIndex >= meta.labels.length) {
return;
}
if (!dataByModel.has(modelName)) {
dataByModel.set(modelName, new Array(meta.labels.length).fill(0));
}
const bucketValues = dataByModel.get(modelName);
bucketValues[bucketIndex] += cost;
});
const datasets = [];
dataByModel.forEach((series, modelName) => {
datasets.push({ label: modelName, data: series.map(value => Number(value.toFixed(4))) });
});
return { totalCost, labels: meta.labels, datasets };
}
const labelSet = new Set();
const costByModelDay = new Map();
normalizedDetails.forEach(({ modelName, cost, timestamp }) => {
const dayLabel = this.formatDayLabel(new Date(timestamp));
if (!dayLabel) {
return;
}
if (!costByModelDay.has(modelName)) { if (!costByModelDay.has(modelName)) {
costByModelDay.set(modelName, new Map()); costByModelDay.set(modelName, new Map());
} }
const dayMap = costByModelDay.get(modelName); const dayMap = costByModelDay.get(modelName);
dayMap.set(dayLabel, (dayMap.get(dayLabel) || 0) + detailCost); dayMap.set(dayLabel, (dayMap.get(dayLabel) || 0) + cost);
labelSet.add(dayLabel);
}); });
const labels = Array.from(labelSet).sort(); const labels = Array.from(labelSet).sort();
@@ -914,7 +950,7 @@ export function destroyCostChart() {
} }
} }
export function initializeCostChart(costData) { export function initializeCostChart(costData, period = 'day') {
const canvas = document.getElementById('cost-chart'); const canvas = document.getElementById('cost-chart');
if (!canvas) { if (!canvas) {
return; return;
@@ -972,7 +1008,7 @@ export function initializeCostChart(costData) {
x: { x: {
title: { title: {
display: true, display: true,
text: i18n.t('usage_stats.by_day') text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
} }
}, },
y: { y: {
@@ -999,11 +1035,17 @@ export function initializeCostChart(costData) {
this.setCostChartPlaceholder(null); this.setCostChartPlaceholder(null);
} }
export function updateCostSummaryAndChart(usage = null) { export function getCostChartPeriod() {
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
return costHourActive ? 'hour' : 'day';
}
export function updateCostSummaryAndChart(usage = null, period = null) {
this.ensureModelPriceState(); this.ensureModelPriceState();
const totalCostEl = document.getElementById('total-cost'); const totalCostEl = document.getElementById('total-cost');
const hasPrices = Object.keys(this.modelPrices || {}).length > 0; const hasPrices = Object.keys(this.modelPrices || {}).length > 0;
const usagePayload = usage || this.currentUsageData; const usagePayload = usage || this.currentUsageData;
const resolvedPeriod = period || this.getCostChartPeriod();
if (!hasPrices) { if (!hasPrices) {
if (totalCostEl) { if (totalCostEl) {
@@ -1023,7 +1065,7 @@ export function updateCostSummaryAndChart(usage = null) {
return; return;
} }
const costData = this.calculateCostData(this.modelPrices, usagePayload); const costData = this.calculateCostData(this.modelPrices, usagePayload, resolvedPeriod);
if (totalCostEl) { if (totalCostEl) {
totalCostEl.textContent = this.formatUsd(costData.totalCost); totalCostEl.textContent = this.formatUsd(costData.totalCost);
} }
@@ -1034,15 +1076,17 @@ export function updateCostSummaryAndChart(usage = null) {
return; return;
} }
this.initializeCostChart(costData); this.initializeCostChart(costData, resolvedPeriod);
} }
// 初始化图表 // 初始化图表
export function initializeCharts() { export function initializeCharts() {
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day'); this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day');
this.initializeTokensChart(tokensHourActive ? 'hour' : 'day'); this.initializeTokensChart(tokensHourActive ? 'hour' : 'day');
this.updateCostSummaryAndChart(this.currentUsageData, costHourActive ? 'hour' : 'day');
} }
// 初始化请求趋势图表 // 初始化请求趋势图表
@@ -1213,6 +1257,20 @@ export function switchTokensPeriod(period) {
} }
} }
export function switchCostPeriod(period) {
if (period !== 'hour' && period !== 'day') {
return;
}
const hourBtn = document.getElementById('cost-hour-btn');
const dayBtn = document.getElementById('cost-day-btn');
if (hourBtn && dayBtn) {
hourBtn.classList.toggle('active', period === 'hour');
dayBtn.classList.toggle('active', period === 'day');
}
this.updateCostSummaryAndChart(this.currentUsageData, period);
}
// 更新API详细统计表格 // 更新API详细统计表格
export function updateApiStatsTable(data) { export function updateApiStatsTable(data) {
const container = document.getElementById('api-stats-table'); const container = document.getElementById('api-stats-table');
@@ -1312,6 +1370,7 @@ export const usageModule = {
extractTotalTokens, extractTotalTokens,
formatUsd, formatUsd,
calculateCostData, calculateCostData,
getCostChartPeriod,
setCostChartPlaceholder, setCostChartPlaceholder,
destroyCostChart, destroyCostChart,
initializeCostChart, initializeCostChart,
@@ -1323,6 +1382,7 @@ export const usageModule = {
getTokensChartData, getTokensChartData,
switchRequestsPeriod, switchRequestsPeriod,
switchTokensPeriod, switchTokensPeriod,
switchCostPeriod,
updateApiStatsTable, updateApiStatsTable,
registerUsageListeners registerUsageListeners
}; };