feat(app.js, i18n, index.html, styles.css): implement model filtering in usage statistics

- Added a model filter dropdown to the usage statistics UI, allowing users to filter data by model.
- Implemented methods to handle model filter changes and update chart data accordingly.
- Enhanced internationalization strings for model filter labels in both English and Chinese.
- Updated styles for the model filter to improve layout and user experience.
This commit is contained in:
Supra4E8C
2025-11-16 11:25:18 +08:00
parent 6928cfed28
commit aa852025a5
4 changed files with 227 additions and 8 deletions

185
app.js
View File

@@ -754,6 +754,7 @@ 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 modelFilterSelect = document.getElementById('model-filter-select');
if (refreshUsageStats) { if (refreshUsageStats) {
refreshUsageStats.addEventListener('click', () => this.loadUsageStats()); refreshUsageStats.addEventListener('click', () => this.loadUsageStats());
@@ -770,6 +771,9 @@ class CLIProxyManager {
if (tokensDayBtn) { if (tokensDayBtn) {
tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day')); tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day'));
} }
if (modelFilterSelect) {
modelFilterSelect.addEventListener('change', (e) => this.handleModelFilterChange(e.target.value));
}
// 模态框 // 模态框
const closeBtn = document.querySelector('.close'); const closeBtn = document.querySelector('.close');
@@ -5856,6 +5860,7 @@ class CLIProxyManager {
requestsChart = null; requestsChart = null;
tokensChart = null; tokensChart = null;
currentUsageData = null; currentUsageData = null;
currentModelFilter = 'all';
// 获取API密钥的统计信息 // 获取API密钥的统计信息
async getKeyStats() { async getKeyStats() {
@@ -5921,6 +5926,7 @@ class CLIProxyManager {
// 更新概览卡片 // 更新概览卡片
this.updateUsageOverview(usage); this.updateUsageOverview(usage);
this.updateModelFilterOptions(usage);
// 读取当前图表周期 // 读取当前图表周期
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
@@ -5938,6 +5944,7 @@ class CLIProxyManager {
} catch (error) { } catch (error) {
console.error('加载使用统计失败:', error); console.error('加载使用统计失败:', error);
this.currentUsageData = null; this.currentUsageData = null;
this.updateModelFilterOptions(null);
// 清空概览数据 // 清空概览数据
['total-requests', 'success-requests', 'failed-requests', 'total-tokens'].forEach(id => { ['total-requests', 'success-requests', 'failed-requests', 'total-tokens'].forEach(id => {
@@ -5971,6 +5978,90 @@ class CLIProxyManager {
document.getElementById('total-tokens').textContent = safeData.total_tokens ?? 0; document.getElementById('total-tokens').textContent = safeData.total_tokens ?? 0;
} }
getModelNamesFromUsage(usage) {
if (!usage) {
return [];
}
const apis = usage.apis || {};
const names = new Set();
Object.values(apis).forEach(apiEntry => {
const models = apiEntry.models || {};
Object.keys(models).forEach(modelName => {
if (modelName) {
names.add(modelName);
}
});
});
return Array.from(names).sort((a, b) => a.localeCompare(b));
}
updateModelFilterOptions(usage) {
const select = document.getElementById('model-filter-select');
if (!select) {
return;
}
const modelNames = this.getModelNamesFromUsage(usage);
const previousSelection = this.currentModelFilter || 'all';
const fragment = document.createDocumentFragment();
const allOption = document.createElement('option');
allOption.value = 'all';
allOption.textContent = i18n.t('usage_stats.model_filter_all');
fragment.appendChild(allOption);
modelNames.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
fragment.appendChild(option);
});
select.innerHTML = '';
select.appendChild(fragment);
let nextSelection = previousSelection;
if (nextSelection !== 'all' && !modelNames.includes(nextSelection)) {
nextSelection = 'all';
}
this.currentModelFilter = nextSelection;
select.value = nextSelection;
select.disabled = modelNames.length === 0;
}
handleModelFilterChange(value) {
const normalized = value || 'all';
if (this.currentModelFilter === normalized) {
return;
}
this.currentModelFilter = normalized;
this.refreshChartsForModelFilter();
}
refreshChartsForModelFilter() {
if (!this.currentUsageData) {
return;
}
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
if (this.requestsChart) {
this.requestsChart.data = this.getRequestsChartData(requestsPeriod);
this.requestsChart.update();
} else {
this.initializeRequestsChart(requestsPeriod);
}
if (this.tokensChart) {
this.tokensChart.data = this.getTokensChartData(tokensPeriod);
this.tokensChart.update();
} else {
this.initializeTokensChart(tokensPeriod);
}
}
// 收集所有请求明细,供图表等复用 // 收集所有请求明细,供图表等复用
collectUsageDetailsFromUsage(usage) { collectUsageDetailsFromUsage(usage) {
if (!usage) { if (!usage) {
@@ -5980,11 +6071,14 @@ class CLIProxyManager {
const details = []; const details = [];
Object.values(apis).forEach(apiEntry => { Object.values(apis).forEach(apiEntry => {
const models = apiEntry.models || {}; const models = apiEntry.models || {};
Object.values(models).forEach(modelEntry => { Object.entries(models).forEach(([modelName, modelEntry]) => {
const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : []; const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : [];
modelDetails.forEach(detail => { modelDetails.forEach(detail => {
if (detail && detail.timestamp) { if (detail && detail.timestamp) {
details.push(detail); details.push({
...detail,
__modelName: modelName
});
} }
}); });
}); });
@@ -6003,6 +6097,7 @@ class CLIProxyManager {
return null; return null;
} }
const modelFilter = this.currentModelFilter || 'all';
const hourMs = 60 * 60 * 1000; const hourMs = 60 * 60 * 1000;
const now = new Date(); const now = new Date();
const currentHour = new Date(now); const currentHour = new Date(now);
@@ -6020,8 +6115,12 @@ class CLIProxyManager {
} }
const latestBucketStart = earliestTime + (values.length - 1) * hourMs; const latestBucketStart = earliestTime + (values.length - 1) * hourMs;
let hasMatch = false;
details.forEach(detail => { details.forEach(detail => {
if (modelFilter !== 'all' && detail.__modelName !== modelFilter) {
return;
}
const timestamp = Date.parse(detail.timestamp); const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) { if (Number.isNaN(timestamp)) {
return; return;
@@ -6044,8 +6143,56 @@ class CLIProxyManager {
} else { } else {
values[bucketIndex] += 1; values[bucketIndex] += 1;
} }
hasMatch = true;
}); });
if (!hasMatch) {
return modelFilter === 'all' ? null : { labels, values };
}
return { labels, values };
}
buildDailySeries(metric = 'requests') {
const details = this.collectUsageDetails();
if (!details.length) {
return null;
}
const modelFilter = this.currentModelFilter || 'all';
const dayBuckets = {};
let hasMatch = false;
details.forEach(detail => {
if (modelFilter !== 'all' && detail.__modelName !== modelFilter) {
return;
}
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
return;
}
const dayLabel = this.formatDayLabel(new Date(timestamp));
if (!dayLabel) {
return;
}
if (!dayBuckets[dayLabel]) {
dayBuckets[dayLabel] = 0;
}
if (metric === 'tokens') {
dayBuckets[dayLabel] += this.extractTotalTokens(detail);
} else {
dayBuckets[dayLabel] += 1;
}
hasMatch = true;
});
if (!hasMatch) {
return modelFilter === 'all' ? null : { labels: [], values: [] };
}
const labels = Object.keys(dayBuckets).sort();
const values = labels.map(label => dayBuckets[label] || 0);
return { labels, values }; return { labels, values };
} }
@@ -6060,6 +6207,16 @@ class CLIProxyManager {
return `${month}-${day} ${hour}:00`; return `${month}-${day} ${hour}:00`;
} }
formatDayLabel(date) {
if (!(date instanceof Date)) {
return '';
}
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}
extractTotalTokens(detail) { extractTotalTokens(detail) {
const tokens = detail?.tokens || {}; const tokens = detail?.tokens || {};
if (typeof tokens.total_tokens === 'number') { if (typeof tokens.total_tokens === 'number') {
@@ -6211,9 +6368,15 @@ class CLIProxyManager {
values = labels.map(hour => dataSource[hour] || 0); values = labels.map(hour => dataSource[hour] || 0);
} }
} else { } else {
dataSource = this.currentUsageData.requests_by_day || {}; const dailySeries = this.buildDailySeries('requests');
labels = Object.keys(dataSource).sort(); if (dailySeries) {
values = labels.map(day => dataSource[day] || 0); labels = dailySeries.labels;
values = dailySeries.values;
} else {
dataSource = this.currentUsageData.requests_by_day || {};
labels = Object.keys(dataSource).sort();
values = labels.map(day => dataSource[day] || 0);
}
} }
return { return {
@@ -6243,9 +6406,15 @@ class CLIProxyManager {
values = labels.map(hour => dataSource[hour] || 0); values = labels.map(hour => dataSource[hour] || 0);
} }
} else { } else {
dataSource = this.currentUsageData.tokens_by_day || {}; const dailySeries = this.buildDailySeries('tokens');
labels = Object.keys(dataSource).sort(); if (dailySeries) {
values = labels.map(day => dataSource[day] || 0); labels = dailySeries.labels;
values = dailySeries.values;
} else {
dataSource = this.currentUsageData.tokens_by_day || {};
labels = Object.keys(dataSource).sort();
values = labels.map(day => dataSource[day] || 0);
}
} }
return { return {

View File

@@ -374,6 +374,8 @@ const i18n = {
'usage_stats.by_hour': '按小时', 'usage_stats.by_hour': '按小时',
'usage_stats.by_day': '按天', 'usage_stats.by_day': '按天',
'usage_stats.refresh': '刷新', 'usage_stats.refresh': '刷新',
'usage_stats.model_filter_label': '模型筛选',
'usage_stats.model_filter_all': '所有模型',
'usage_stats.no_data': '暂无数据', 'usage_stats.no_data': '暂无数据',
'usage_stats.loading_error': '加载失败', 'usage_stats.loading_error': '加载失败',
'usage_stats.api_endpoint': 'API端点', 'usage_stats.api_endpoint': 'API端点',
@@ -875,6 +877,8 @@ const i18n = {
'usage_stats.by_hour': 'By Hour', 'usage_stats.by_hour': 'By Hour',
'usage_stats.by_day': 'By Day', 'usage_stats.by_day': 'By Day',
'usage_stats.refresh': 'Refresh', 'usage_stats.refresh': 'Refresh',
'usage_stats.model_filter_label': 'Model Filter',
'usage_stats.model_filter_all': 'All Models',
'usage_stats.no_data': 'No Data Available', 'usage_stats.no_data': 'No Data Available',
'usage_stats.loading_error': 'Loading Failed', 'usage_stats.loading_error': 'Loading Failed',
'usage_stats.api_endpoint': 'API Endpoint', 'usage_stats.api_endpoint': 'API Endpoint',

View File

@@ -825,6 +825,16 @@
</div> </div>
</div> </div>
<!-- 模型筛选 -->
<div class="usage-filter-bar">
<div class="usage-filter-group">
<label for="model-filter-select" data-i18n="usage_stats.model_filter_label">模型筛选</label>
<select id="model-filter-select" class="model-filter-select" disabled>
<option value="all" data-i18n="usage_stats.model_filter_all">所有模型</option>
</select>
</div>
</div>
<!-- 图表区域 --> <!-- 图表区域 -->
<div class="charts-container"> <div class="charts-container">
<!-- 请求趋势图 --> <!-- 请求趋势图 -->

View File

@@ -2703,6 +2703,42 @@ input:checked+.slider:before {
margin-bottom: 30px; margin-bottom: 30px;
} }
.usage-filter-bar {
display: flex;
justify-content: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.usage-filter-group {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 220px;
}
.usage-filter-group label {
font-weight: 600;
color: var(--text-secondary);
font-size: 14px;
}
.model-filter-select {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 14px;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 40px;
font-size: 14px;
}
.model-filter-select:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.stat-card { .stat-card {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);