mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
feat: implement model pricing functionality with UI elements, storage management, and cost calculation
This commit is contained in:
19
app.js
19
app.js
@@ -504,6 +504,9 @@ class CLIProxyManager {
|
|||||||
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 chartLineSelects = document.querySelectorAll('.chart-line-select');
|
const chartLineSelects = document.querySelectorAll('.chart-line-select');
|
||||||
|
const modelPriceForm = document.getElementById('model-price-form');
|
||||||
|
const resetModelPricesBtn = document.getElementById('reset-model-prices');
|
||||||
|
const modelPriceSelect = document.getElementById('model-price-model-select');
|
||||||
|
|
||||||
if (refreshUsageStats) {
|
if (refreshUsageStats) {
|
||||||
refreshUsageStats.addEventListener('click', () => this.loadUsageStats());
|
refreshUsageStats.addEventListener('click', () => this.loadUsageStats());
|
||||||
@@ -528,6 +531,18 @@ class CLIProxyManager {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (modelPriceForm) {
|
||||||
|
modelPriceForm.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleModelPriceSubmit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (resetModelPricesBtn) {
|
||||||
|
resetModelPricesBtn.addEventListener('click', () => this.handleModelPriceReset());
|
||||||
|
}
|
||||||
|
if (modelPriceSelect) {
|
||||||
|
modelPriceSelect.addEventListener('change', () => this.prefillModelPriceInputs());
|
||||||
|
}
|
||||||
|
|
||||||
// 模态框
|
// 模态框
|
||||||
const closeBtn = document.querySelector('.close');
|
const closeBtn = document.querySelector('.close');
|
||||||
@@ -617,6 +632,7 @@ class CLIProxyManager {
|
|||||||
// 使用统计状态
|
// 使用统计状态
|
||||||
requestsChart = null;
|
requestsChart = null;
|
||||||
tokensChart = null;
|
tokensChart = null;
|
||||||
|
costChart = null;
|
||||||
currentUsageData = null;
|
currentUsageData = null;
|
||||||
chartLineSelections = ['none', 'none', 'none'];
|
chartLineSelections = ['none', 'none', 'none'];
|
||||||
chartLineSelectIds = ['chart-line-select-0', 'chart-line-select-1', 'chart-line-select-2'];
|
chartLineSelectIds = ['chart-line-select-0', 'chart-line-select-1', 'chart-line-select-2'];
|
||||||
@@ -625,6 +641,9 @@ class CLIProxyManager {
|
|||||||
{ borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' },
|
{ borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' },
|
||||||
{ borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' }
|
{ borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' }
|
||||||
];
|
];
|
||||||
|
modelPriceStorageKey = 'cli-proxy-model-prices-v2';
|
||||||
|
modelPrices = {};
|
||||||
|
modelPriceInitialized = false;
|
||||||
|
|
||||||
showModal() {
|
showModal() {
|
||||||
const modal = document.getElementById('modal');
|
const modal = document.getElementById('modal');
|
||||||
|
|||||||
12
build.cjs
12
build.cjs
@@ -171,14 +171,14 @@ function build() {
|
|||||||
|
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
'<link rel="stylesheet" href="styles.css">',
|
'<link rel="stylesheet" href="styles.css">',
|
||||||
`<style>
|
() => `<style>
|
||||||
${css}
|
${css}
|
||||||
</style>`
|
</style>`
|
||||||
);
|
);
|
||||||
|
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
'<script src="i18n.js"></script>',
|
'<script src="i18n.js"></script>',
|
||||||
`<script>
|
() => `<script>
|
||||||
${i18n}
|
${i18n}
|
||||||
</script>`
|
</script>`
|
||||||
);
|
);
|
||||||
@@ -187,7 +187,7 @@ ${i18n}
|
|||||||
if (scriptTagRegex.test(html)) {
|
if (scriptTagRegex.test(html)) {
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
scriptTagRegex,
|
scriptTagRegex,
|
||||||
`<script>
|
() => `<script>
|
||||||
${app}
|
${app}
|
||||||
</script>`
|
</script>`
|
||||||
);
|
);
|
||||||
@@ -198,8 +198,10 @@ ${app}
|
|||||||
const logoDataUrl = loadLogoDataUrl();
|
const logoDataUrl = loadLogoDataUrl();
|
||||||
if (logoDataUrl) {
|
if (logoDataUrl) {
|
||||||
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
|
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
|
||||||
if (html.includes('</body>')) {
|
const closingBodyTag = '</body>';
|
||||||
html = html.replace('</body>', `${logoScript}\n</body>`);
|
const closingBodyIndex = html.lastIndexOf(closingBodyTag);
|
||||||
|
if (closingBodyIndex !== -1) {
|
||||||
|
html = `${html.slice(0, closingBodyIndex)}${logoScript}\n${closingBodyTag}${html.slice(closingBodyIndex + closingBodyTag.length)}`;
|
||||||
} else {
|
} else {
|
||||||
html += `\n${logoScript}`;
|
html += `\n${logoScript}`;
|
||||||
}
|
}
|
||||||
|
|||||||
38
i18n.js
38
i18n.js
@@ -430,6 +430,25 @@ const i18n = {
|
|||||||
'usage_stats.tokens_count': 'Token数量',
|
'usage_stats.tokens_count': 'Token数量',
|
||||||
'usage_stats.models': '模型统计',
|
'usage_stats.models': '模型统计',
|
||||||
'usage_stats.success_rate': '成功率',
|
'usage_stats.success_rate': '成功率',
|
||||||
|
'usage_stats.total_cost': '总花费',
|
||||||
|
'usage_stats.total_cost_hint': '基于已设置的模型单价',
|
||||||
|
'usage_stats.model_price_title': '模型价格',
|
||||||
|
'usage_stats.model_price_reset': '清除价格',
|
||||||
|
'usage_stats.model_price_model_label': '选择模型',
|
||||||
|
'usage_stats.model_price_select_placeholder': '选择模型',
|
||||||
|
'usage_stats.model_price_select_hint': '模型列表来自使用统计明细',
|
||||||
|
'usage_stats.model_price_prompt': '提示价格 ($/1M tokens)',
|
||||||
|
'usage_stats.model_price_completion': '补全价格 ($/1M tokens)',
|
||||||
|
'usage_stats.model_price_save': '保存价格',
|
||||||
|
'usage_stats.model_price_empty': '暂未设置任何模型价格',
|
||||||
|
'usage_stats.model_price_model': '模型',
|
||||||
|
'usage_stats.model_price_saved': '模型价格已保存',
|
||||||
|
'usage_stats.model_price_model_required': '请选择要设置价格的模型',
|
||||||
|
'usage_stats.cost_trend': '花费统计',
|
||||||
|
'usage_stats.cost_axis_label': '花费 ($)',
|
||||||
|
'usage_stats.cost_need_price': '请先设置模型价格',
|
||||||
|
'usage_stats.cost_need_usage': '暂无使用数据,无法计算花费',
|
||||||
|
'usage_stats.cost_no_data': '没有可计算的花费数据',
|
||||||
'stats.success': '成功',
|
'stats.success': '成功',
|
||||||
'stats.failure': '失败',
|
'stats.failure': '失败',
|
||||||
|
|
||||||
@@ -982,6 +1001,25 @@ const i18n = {
|
|||||||
'usage_stats.tokens_count': 'Token Count',
|
'usage_stats.tokens_count': 'Token Count',
|
||||||
'usage_stats.models': 'Model Statistics',
|
'usage_stats.models': 'Model Statistics',
|
||||||
'usage_stats.success_rate': 'Success Rate',
|
'usage_stats.success_rate': 'Success Rate',
|
||||||
|
'usage_stats.total_cost': 'Total Cost',
|
||||||
|
'usage_stats.total_cost_hint': 'Based on configured model pricing',
|
||||||
|
'usage_stats.model_price_title': 'Model Pricing',
|
||||||
|
'usage_stats.model_price_reset': 'Clear Prices',
|
||||||
|
'usage_stats.model_price_model_label': 'Model',
|
||||||
|
'usage_stats.model_price_select_placeholder': 'Choose a model',
|
||||||
|
'usage_stats.model_price_select_hint': 'Models come from usage details',
|
||||||
|
'usage_stats.model_price_prompt': 'Prompt price ($/1M tokens)',
|
||||||
|
'usage_stats.model_price_completion': 'Completion price ($/1M tokens)',
|
||||||
|
'usage_stats.model_price_save': 'Save Price',
|
||||||
|
'usage_stats.model_price_empty': 'No model prices set',
|
||||||
|
'usage_stats.model_price_model': 'Model',
|
||||||
|
'usage_stats.model_price_saved': 'Model price saved',
|
||||||
|
'usage_stats.model_price_model_required': 'Please choose a model to set pricing',
|
||||||
|
'usage_stats.cost_trend': 'Cost Overview',
|
||||||
|
'usage_stats.cost_axis_label': 'Cost ($)',
|
||||||
|
'usage_stats.cost_need_price': 'Set a model price to view cost stats',
|
||||||
|
'usage_stats.cost_need_usage': 'No usage data available to calculate cost',
|
||||||
|
'usage_stats.cost_no_data': 'No cost data yet',
|
||||||
'stats.success': 'Success',
|
'stats.success': 'Success',
|
||||||
'stats.failure': 'Failure',
|
'stats.failure': 'Failure',
|
||||||
|
|
||||||
|
|||||||
63
index.html
63
index.html
@@ -916,6 +916,17 @@
|
|||||||
<div class="stat-label" data-i18n="usage_stats.tpm_30m">TPM(近30分钟)</div>
|
<div class="stat-label" data-i18n="usage_stats.tpm_30m">TPM(近30分钟)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card cost-summary-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fas fa-dollar-sign"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-number" id="total-cost">--</div>
|
||||||
|
<div class="stat-label" data-i18n="usage_stats.total_cost">总花费</div>
|
||||||
|
<div class="stat-subtext" id="total-cost-hint" data-i18n="usage_stats.total_cost_hint">基于已设置的模型单价</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图表曲线选择 -->
|
<!-- 图表曲线选择 -->
|
||||||
@@ -984,6 +995,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card chart-card cost-chart-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-sack-dollar"></i> <span data-i18n="usage_stats.cost_trend">花费统计</span></h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="cost-chart"></canvas>
|
||||||
|
<div id="cost-chart-placeholder" class="chart-placeholder" data-i18n="usage_stats.cost_need_price">请先设置模型价格</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API详细统计 -->
|
<!-- API详细统计 -->
|
||||||
@@ -1001,6 +1024,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card cost-config-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-tags"></i> <span data-i18n="usage_stats.model_price_title">模型价格</span></h3>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button type="button" id="reset-model-prices" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-rotate-left"></i> <span data-i18n="usage_stats.model_price_reset">清除价格</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<form id="model-price-form" class="model-price-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="model-price-model-select" data-i18n="usage_stats.model_price_model_label">选择模型</label>
|
||||||
|
<select id="model-price-model-select" class="model-filter-select">
|
||||||
|
<option value="" data-i18n="usage_stats.model_price_select_placeholder">选择模型</option>
|
||||||
|
</select>
|
||||||
|
<p class="form-hint" data-i18n="usage_stats.model_price_select_hint">模型列表来自使用统计</p>
|
||||||
|
</div>
|
||||||
|
<div class="price-input-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="model-price-prompt" data-i18n="usage_stats.model_price_prompt">提示价格 ($/1M tokens)</label>
|
||||||
|
<input type="number" step="0.0001" min="0" id="model-price-prompt" placeholder="0.0000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="model-price-completion" data-i18n="usage_stats.model_price_completion">补全价格 ($/1M tokens)</label>
|
||||||
|
<input type="number" step="0.0001" min="0" id="model-price-completion" placeholder="0.0000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="price-form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i> <span data-i18n="usage_stats.model_price_save">保存价格</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="model-price-list" id="model-price-list">
|
||||||
|
<div class="loading-placeholder" data-i18n="common.loading">正在加载...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 配置管理 -->
|
<!-- 配置管理 -->
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
const DEFAULT_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
|
||||||
|
const LEGACY_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices';
|
||||||
|
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
||||||
|
|
||||||
// 获取API密钥的统计信息
|
// 获取API密钥的统计信息
|
||||||
export async function getKeyStats(usageData = null) {
|
export async function getKeyStats(usageData = null) {
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +87,7 @@ export async function loadUsageStats(usageData = null) {
|
|||||||
usage = response?.usage || null;
|
usage = response?.usage || null;
|
||||||
}
|
}
|
||||||
this.currentUsageData = usage;
|
this.currentUsageData = usage;
|
||||||
|
this.ensureModelPriceState();
|
||||||
|
|
||||||
if (!usage) {
|
if (!usage) {
|
||||||
throw new Error('usage payload missing');
|
throw new Error('usage payload missing');
|
||||||
@@ -91,6 +96,8 @@ export async function loadUsageStats(usageData = null) {
|
|||||||
// 更新概览卡片
|
// 更新概览卡片
|
||||||
this.updateUsageOverview(usage);
|
this.updateUsageOverview(usage);
|
||||||
this.updateChartLineSelectors(usage);
|
this.updateChartLineSelectors(usage);
|
||||||
|
this.renderModelPriceOptions(usage);
|
||||||
|
this.renderSavedModelPrices();
|
||||||
|
|
||||||
// 读取当前图表周期
|
// 读取当前图表周期
|
||||||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||||||
@@ -104,11 +111,16 @@ export async function loadUsageStats(usageData = null) {
|
|||||||
|
|
||||||
// 更新API详细统计表格
|
// 更新API详细统计表格
|
||||||
this.updateApiStatsTable(usage);
|
this.updateApiStatsTable(usage);
|
||||||
|
this.updateCostSummaryAndChart(usage);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载使用统计失败:', error);
|
console.error('加载使用统计失败:', error);
|
||||||
this.currentUsageData = null;
|
this.currentUsageData = null;
|
||||||
this.updateChartLineSelectors(null);
|
this.updateChartLineSelectors(null);
|
||||||
|
this.ensureModelPriceState();
|
||||||
|
this.renderModelPriceOptions(null);
|
||||||
|
this.renderSavedModelPrices();
|
||||||
|
this.updateCostSummaryAndChart(null);
|
||||||
|
|
||||||
// 清空概览数据
|
// 清空概览数据
|
||||||
['total-requests', 'success-requests', 'failed-requests', 'total-tokens', 'cached-tokens', 'reasoning-tokens', 'rpm-30m', 'tpm-30m'].forEach(id => {
|
['total-requests', 'success-requests', 'failed-requests', 'total-tokens', 'cached-tokens', 'reasoning-tokens', 'rpm-30m', 'tpm-30m'].forEach(id => {
|
||||||
@@ -346,6 +358,234 @@ export function collectUsageDetails() {
|
|||||||
return this.collectUsageDetailsFromUsage(this.currentUsageData);
|
return this.collectUsageDetailsFromUsage(this.currentUsageData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function migrateLegacyModelPrices() {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||||||
|
const hasCurrent = localStorage.getItem(storageKey);
|
||||||
|
const legacyRaw = localStorage.getItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!legacyRaw || hasCurrent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(legacyRaw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrated = {};
|
||||||
|
Object.entries(parsed).forEach(([model, price]) => {
|
||||||
|
if (!model) return;
|
||||||
|
const prompt = Number(price?.prompt);
|
||||||
|
const completion = Number(price?.completion);
|
||||||
|
const hasPrompt = Number.isFinite(prompt);
|
||||||
|
const hasCompletion = Number.isFinite(completion);
|
||||||
|
if (!hasPrompt && !hasCompletion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
migrated[model] = {
|
||||||
|
prompt: hasPrompt && prompt >= 0 ? prompt * 1000 : 0,
|
||||||
|
completion: hasCompletion && completion >= 0 ? completion * 1000 : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(migrated).length) {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(migrated));
|
||||||
|
}
|
||||||
|
localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('迁移模型价格失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureModelPriceState() {
|
||||||
|
if (this.modelPriceInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.modelPriceStorageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||||||
|
this.migrateLegacyModelPrices();
|
||||||
|
this.modelPrices = this.loadModelPricesFromStorage();
|
||||||
|
this.modelPriceInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadModelPricesFromStorage() {
|
||||||
|
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const normalized = {};
|
||||||
|
Object.entries(parsed).forEach(([model, price]) => {
|
||||||
|
if (!model) return;
|
||||||
|
const prompt = Number(price?.prompt);
|
||||||
|
const completion = Number(price?.completion);
|
||||||
|
if (!Number.isFinite(prompt) && !Number.isFinite(completion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalized[model] = {
|
||||||
|
prompt: Number.isFinite(prompt) && prompt >= 0 ? prompt : 0,
|
||||||
|
completion: Number.isFinite(completion) && completion >= 0 ? completion : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('读取模型价格失败:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistModelPrices(prices = {}) {
|
||||||
|
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||||||
|
this.modelPrices = prices;
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(prices));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('保存模型价格失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderModelPriceOptions(usage = null) {
|
||||||
|
const select = document.getElementById('model-price-model-select');
|
||||||
|
if (!select) return;
|
||||||
|
const models = this.getModelNamesFromUsage(usage);
|
||||||
|
const previousValue = select.value;
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
const placeholderOption = document.createElement('option');
|
||||||
|
placeholderOption.value = '';
|
||||||
|
placeholderOption.textContent = i18n.t('usage_stats.model_price_select_placeholder');
|
||||||
|
select.appendChild(placeholderOption);
|
||||||
|
|
||||||
|
models.forEach(name => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = name;
|
||||||
|
option.textContent = name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
select.disabled = models.length === 0;
|
||||||
|
if (models.includes(previousValue)) {
|
||||||
|
select.value = previousValue;
|
||||||
|
} else {
|
||||||
|
select.value = '';
|
||||||
|
}
|
||||||
|
this.prefillModelPriceInputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSavedModelPrices() {
|
||||||
|
const container = document.getElementById('model-price-list');
|
||||||
|
if (!container) return;
|
||||||
|
const entries = Object.entries(this.modelPrices || {});
|
||||||
|
if (!entries.length) {
|
||||||
|
container.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.model_price_empty')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = entries.map(([model, price]) => {
|
||||||
|
const prompt = Number(price?.prompt) || 0;
|
||||||
|
const completion = Number(price?.completion) || 0;
|
||||||
|
return `
|
||||||
|
<div class="model-price-row">
|
||||||
|
<span class="model-name">${model}</span>
|
||||||
|
<span>$${prompt.toFixed(4)} / 1M</span>
|
||||||
|
<span>$${completion.toFixed(4)} / 1M</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="model-price-table">
|
||||||
|
<div class="model-price-header">
|
||||||
|
<span>${i18n.t('usage_stats.model_price_model')}</span>
|
||||||
|
<span>${i18n.t('usage_stats.model_price_prompt')}</span>
|
||||||
|
<span>${i18n.t('usage_stats.model_price_completion')}</span>
|
||||||
|
</div>
|
||||||
|
${rows}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefillModelPriceInputs() {
|
||||||
|
const select = document.getElementById('model-price-model-select');
|
||||||
|
const promptInput = document.getElementById('model-price-prompt');
|
||||||
|
const completionInput = document.getElementById('model-price-completion');
|
||||||
|
if (!select || !promptInput || !completionInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = (select.value || '').trim();
|
||||||
|
const price = this.modelPrices?.[model];
|
||||||
|
if (price) {
|
||||||
|
promptInput.value = Number.isFinite(price.prompt) ? price.prompt : '';
|
||||||
|
completionInput.value = Number.isFinite(price.completion) ? price.completion : '';
|
||||||
|
} else {
|
||||||
|
promptInput.value = '';
|
||||||
|
completionInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePriceValue(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Number(parsed.toFixed(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleModelPriceSubmit() {
|
||||||
|
this.ensureModelPriceState();
|
||||||
|
const select = document.getElementById('model-price-model-select');
|
||||||
|
const promptInput = document.getElementById('model-price-prompt');
|
||||||
|
const completionInput = document.getElementById('model-price-completion');
|
||||||
|
if (!select || !promptInput || !completionInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = (select.value || '').trim();
|
||||||
|
if (!model) {
|
||||||
|
this.showNotification(i18n.t('usage_stats.model_price_model_required'), 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prompt = this.normalizePriceValue(promptInput.value);
|
||||||
|
const completion = this.normalizePriceValue(completionInput.value);
|
||||||
|
|
||||||
|
const next = { ...(this.modelPrices || {}) };
|
||||||
|
next[model] = { prompt, completion };
|
||||||
|
this.persistModelPrices(next);
|
||||||
|
this.renderSavedModelPrices();
|
||||||
|
this.updateCostSummaryAndChart();
|
||||||
|
this.showNotification(i18n.t('usage_stats.model_price_saved'), 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleModelPriceReset() {
|
||||||
|
this.persistModelPrices({});
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const key = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('清除模型价格失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.renderSavedModelPrices();
|
||||||
|
this.prefillModelPriceInputs();
|
||||||
|
this.updateCostSummaryAndChart();
|
||||||
|
}
|
||||||
|
|
||||||
export function calculateTokenBreakdown(usage = null) {
|
export function calculateTokenBreakdown(usage = null) {
|
||||||
const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData);
|
const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData);
|
||||||
if (!details.length) {
|
if (!details.length) {
|
||||||
@@ -572,6 +812,231 @@ export function extractTotalTokens(detail) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatUsd(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
return '$0.00';
|
||||||
|
}
|
||||||
|
const fixed = num.toFixed(2);
|
||||||
|
const parts = Number(fixed).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
return `$${parts}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateCostData(prices = null, usage = null) {
|
||||||
|
const priceTable = prices || this.modelPrices || {};
|
||||||
|
const usagePayload = usage || this.currentUsageData;
|
||||||
|
const entries = Object.entries(priceTable || {});
|
||||||
|
const result = { totalCost: 0, labels: [], datasets: [] };
|
||||||
|
|
||||||
|
if (!entries.length || !usagePayload) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = this.collectUsageDetailsFromUsage(usagePayload);
|
||||||
|
if (!details.length) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelSet = new Set();
|
||||||
|
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 price = priceTable[modelName];
|
||||||
|
if (!price) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = detail?.tokens || {};
|
||||||
|
const promptTokens = Number(tokens.input_tokens) || 0;
|
||||||
|
const completionTokens = Number(tokens.output_tokens) || 0;
|
||||||
|
const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0);
|
||||||
|
const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||||||
|
const detailCost = promptCost + completionCost;
|
||||||
|
|
||||||
|
if (!Number.isFinite(detailCost) || detailCost <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCost += detailCost;
|
||||||
|
labelSet.add(dayLabel);
|
||||||
|
|
||||||
|
if (!costByModelDay.has(modelName)) {
|
||||||
|
costByModelDay.set(modelName, new Map());
|
||||||
|
}
|
||||||
|
const dayMap = costByModelDay.get(modelName);
|
||||||
|
dayMap.set(dayLabel, (dayMap.get(dayLabel) || 0) + detailCost);
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = Array.from(labelSet).sort();
|
||||||
|
const datasets = [];
|
||||||
|
costByModelDay.forEach((dayMap, modelName) => {
|
||||||
|
const series = labels.map(label => Number((dayMap.get(label) || 0).toFixed(4)));
|
||||||
|
datasets.push({ label: modelName, data: series });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { totalCost, labels, datasets };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCostChartPlaceholder(messageKey = null) {
|
||||||
|
const placeholder = document.getElementById('cost-chart-placeholder');
|
||||||
|
const canvas = document.getElementById('cost-chart');
|
||||||
|
if (!placeholder || !canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (messageKey) {
|
||||||
|
placeholder.textContent = i18n.t(messageKey);
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyCostChart() {
|
||||||
|
if (this.costChart) {
|
||||||
|
this.costChart.destroy();
|
||||||
|
this.costChart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeCostChart(costData) {
|
||||||
|
const canvas = document.getElementById('cost-chart');
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.destroyCostChart();
|
||||||
|
|
||||||
|
const datasets = (costData.datasets || []).map((dataset, index) => {
|
||||||
|
const style = this.chartLineStyles[index % this.chartLineStyles.length] || this.chartLineStyles[0];
|
||||||
|
return {
|
||||||
|
...dataset,
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
backgroundColor: style.backgroundColor || 'rgba(59, 130, 246, 0.15)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointBackgroundColor: style.borderColor,
|
||||||
|
pointBorderColor: '#ffffff',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
pointRadius: dataset.data.some(v => v > 0) ? 4 : 3
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.costChart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: costData.labels || [],
|
||||||
|
datasets
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'top',
|
||||||
|
align: 'start',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: context => {
|
||||||
|
const label = context.dataset.label || '';
|
||||||
|
const value = Number(context.parsed.y) || 0;
|
||||||
|
return `${label}: ${this.formatUsd(value)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: i18n.t('usage_stats.by_day')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: i18n.t('usage_stats.cost_axis_label')
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: (value) => this.formatUsd(value).replace('$', '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
line: {
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
borderWidth: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.setCostChartPlaceholder(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCostSummaryAndChart(usage = null) {
|
||||||
|
this.ensureModelPriceState();
|
||||||
|
const totalCostEl = document.getElementById('total-cost');
|
||||||
|
const hasPrices = Object.keys(this.modelPrices || {}).length > 0;
|
||||||
|
const usagePayload = usage || this.currentUsageData;
|
||||||
|
|
||||||
|
if (!hasPrices) {
|
||||||
|
if (totalCostEl) {
|
||||||
|
totalCostEl.textContent = '--';
|
||||||
|
}
|
||||||
|
this.destroyCostChart();
|
||||||
|
this.setCostChartPlaceholder('usage_stats.cost_need_price');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usagePayload) {
|
||||||
|
if (totalCostEl) {
|
||||||
|
totalCostEl.textContent = '--';
|
||||||
|
}
|
||||||
|
this.destroyCostChart();
|
||||||
|
this.setCostChartPlaceholder('usage_stats.cost_need_usage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const costData = this.calculateCostData(this.modelPrices, usagePayload);
|
||||||
|
if (totalCostEl) {
|
||||||
|
totalCostEl.textContent = this.formatUsd(costData.totalCost);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!costData.labels.length || !costData.datasets.length) {
|
||||||
|
this.destroyCostChart();
|
||||||
|
this.setCostChartPlaceholder('usage_stats.cost_no_data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initializeCostChart(costData);
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化图表
|
// 初始化图表
|
||||||
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');
|
||||||
@@ -824,6 +1289,16 @@ export const usageModule = {
|
|||||||
getActiveChartLineSelections,
|
getActiveChartLineSelections,
|
||||||
collectUsageDetailsFromUsage,
|
collectUsageDetailsFromUsage,
|
||||||
collectUsageDetails,
|
collectUsageDetails,
|
||||||
|
migrateLegacyModelPrices,
|
||||||
|
ensureModelPriceState,
|
||||||
|
loadModelPricesFromStorage,
|
||||||
|
persistModelPrices,
|
||||||
|
renderModelPriceOptions,
|
||||||
|
renderSavedModelPrices,
|
||||||
|
prefillModelPriceInputs,
|
||||||
|
normalizePriceValue,
|
||||||
|
handleModelPriceSubmit,
|
||||||
|
handleModelPriceReset,
|
||||||
calculateTokenBreakdown,
|
calculateTokenBreakdown,
|
||||||
calculateRecentPerMinuteRates,
|
calculateRecentPerMinuteRates,
|
||||||
createHourlyBucketMeta,
|
createHourlyBucketMeta,
|
||||||
@@ -835,6 +1310,12 @@ export const usageModule = {
|
|||||||
formatPerMinuteValue,
|
formatPerMinuteValue,
|
||||||
formatDayLabel,
|
formatDayLabel,
|
||||||
extractTotalTokens,
|
extractTotalTokens,
|
||||||
|
formatUsd,
|
||||||
|
calculateCostData,
|
||||||
|
setCostChartPlaceholder,
|
||||||
|
destroyCostChart,
|
||||||
|
initializeCostChart,
|
||||||
|
updateCostSummaryAndChart,
|
||||||
initializeCharts,
|
initializeCharts,
|
||||||
initializeRequestsChart,
|
initializeRequestsChart,
|
||||||
initializeTokensChart,
|
initializeTokensChart,
|
||||||
|
|||||||
82
styles.css
82
styles.css
@@ -3108,6 +3108,10 @@ input:checked+.slider:before {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cost-summary-card .stat-icon {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
.charts-container {
|
.charts-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -3199,6 +3203,84 @@ input:checked+.slider:before {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cost-config-card .card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-price-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-input-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.price-input-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-price-list {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-price-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-price-header,
|
||||||
|
.model-price-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-price-header {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-price-row {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-chart-card {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.model-item {
|
.model-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
Reference in New Issue
Block a user