mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-02 19:00:49 +08:00
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:
173
app.js
173
app.js
@@ -754,6 +754,7 @@ class CLIProxyManager {
|
||||
const requestsDayBtn = document.getElementById('requests-day-btn');
|
||||
const tokensHourBtn = document.getElementById('tokens-hour-btn');
|
||||
const tokensDayBtn = document.getElementById('tokens-day-btn');
|
||||
const modelFilterSelect = document.getElementById('model-filter-select');
|
||||
|
||||
if (refreshUsageStats) {
|
||||
refreshUsageStats.addEventListener('click', () => this.loadUsageStats());
|
||||
@@ -770,6 +771,9 @@ class CLIProxyManager {
|
||||
if (tokensDayBtn) {
|
||||
tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day'));
|
||||
}
|
||||
if (modelFilterSelect) {
|
||||
modelFilterSelect.addEventListener('change', (e) => this.handleModelFilterChange(e.target.value));
|
||||
}
|
||||
|
||||
// 模态框
|
||||
const closeBtn = document.querySelector('.close');
|
||||
@@ -5856,6 +5860,7 @@ class CLIProxyManager {
|
||||
requestsChart = null;
|
||||
tokensChart = null;
|
||||
currentUsageData = null;
|
||||
currentModelFilter = 'all';
|
||||
|
||||
// 获取API密钥的统计信息
|
||||
async getKeyStats() {
|
||||
@@ -5921,6 +5926,7 @@ class CLIProxyManager {
|
||||
|
||||
// 更新概览卡片
|
||||
this.updateUsageOverview(usage);
|
||||
this.updateModelFilterOptions(usage);
|
||||
|
||||
// 读取当前图表周期
|
||||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||||
@@ -5938,6 +5944,7 @@ class CLIProxyManager {
|
||||
} catch (error) {
|
||||
console.error('加载使用统计失败:', error);
|
||||
this.currentUsageData = null;
|
||||
this.updateModelFilterOptions(null);
|
||||
|
||||
// 清空概览数据
|
||||
['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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!usage) {
|
||||
@@ -5980,11 +6071,14 @@ class CLIProxyManager {
|
||||
const details = [];
|
||||
Object.values(apis).forEach(apiEntry => {
|
||||
const models = apiEntry.models || {};
|
||||
Object.values(models).forEach(modelEntry => {
|
||||
Object.entries(models).forEach(([modelName, modelEntry]) => {
|
||||
const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : [];
|
||||
modelDetails.forEach(detail => {
|
||||
if (detail && detail.timestamp) {
|
||||
details.push(detail);
|
||||
details.push({
|
||||
...detail,
|
||||
__modelName: modelName
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6003,6 +6097,7 @@ class CLIProxyManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelFilter = this.currentModelFilter || 'all';
|
||||
const hourMs = 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const currentHour = new Date(now);
|
||||
@@ -6020,8 +6115,12 @@ class CLIProxyManager {
|
||||
}
|
||||
|
||||
const latestBucketStart = earliestTime + (values.length - 1) * hourMs;
|
||||
let hasMatch = false;
|
||||
|
||||
details.forEach(detail => {
|
||||
if (modelFilter !== 'all' && detail.__modelName !== modelFilter) {
|
||||
return;
|
||||
}
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return;
|
||||
@@ -6044,8 +6143,56 @@ class CLIProxyManager {
|
||||
} else {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -6060,6 +6207,16 @@ class CLIProxyManager {
|
||||
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) {
|
||||
const tokens = detail?.tokens || {};
|
||||
if (typeof tokens.total_tokens === 'number') {
|
||||
@@ -6210,11 +6367,17 @@ class CLIProxyManager {
|
||||
labels = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
|
||||
values = labels.map(hour => dataSource[hour] || 0);
|
||||
}
|
||||
} else {
|
||||
const dailySeries = this.buildDailySeries('requests');
|
||||
if (dailySeries) {
|
||||
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 {
|
||||
labels: labels,
|
||||
@@ -6242,11 +6405,17 @@ class CLIProxyManager {
|
||||
labels = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
|
||||
values = labels.map(hour => dataSource[hour] || 0);
|
||||
}
|
||||
} else {
|
||||
const dailySeries = this.buildDailySeries('tokens');
|
||||
if (dailySeries) {
|
||||
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 {
|
||||
labels: labels,
|
||||
|
||||
4
i18n.js
4
i18n.js
@@ -374,6 +374,8 @@ const i18n = {
|
||||
'usage_stats.by_hour': '按小时',
|
||||
'usage_stats.by_day': '按天',
|
||||
'usage_stats.refresh': '刷新',
|
||||
'usage_stats.model_filter_label': '模型筛选',
|
||||
'usage_stats.model_filter_all': '所有模型',
|
||||
'usage_stats.no_data': '暂无数据',
|
||||
'usage_stats.loading_error': '加载失败',
|
||||
'usage_stats.api_endpoint': 'API端点',
|
||||
@@ -875,6 +877,8 @@ const i18n = {
|
||||
'usage_stats.by_hour': 'By Hour',
|
||||
'usage_stats.by_day': 'By Day',
|
||||
'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.loading_error': 'Loading Failed',
|
||||
'usage_stats.api_endpoint': 'API Endpoint',
|
||||
|
||||
10
index.html
10
index.html
@@ -825,6 +825,16 @@
|
||||
</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">
|
||||
<!-- 请求趋势图 -->
|
||||
|
||||
36
styles.css
36
styles.css
@@ -2703,6 +2703,42 @@ input:checked+.slider:before {
|
||||
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 {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
Reference in New Issue
Block a user