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 tokensDayBtn = document.getElementById('tokens-day-btn');
|
||||
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) {
|
||||
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');
|
||||
@@ -617,6 +632,7 @@ class CLIProxyManager {
|
||||
// 使用统计状态
|
||||
requestsChart = null;
|
||||
tokensChart = null;
|
||||
costChart = null;
|
||||
currentUsageData = null;
|
||||
chartLineSelections = ['none', 'none', 'none'];
|
||||
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: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' }
|
||||
];
|
||||
modelPriceStorageKey = 'cli-proxy-model-prices-v2';
|
||||
modelPrices = {};
|
||||
modelPriceInitialized = false;
|
||||
|
||||
showModal() {
|
||||
const modal = document.getElementById('modal');
|
||||
|
||||
64
build.cjs
64
build.cjs
@@ -169,42 +169,44 @@ function build() {
|
||||
console.log(`使用版本号: ${version}`);
|
||||
html = html.replace(/__VERSION__/g, version);
|
||||
|
||||
html = html.replace(
|
||||
'<link rel="stylesheet" href="styles.css">',
|
||||
`<style>
|
||||
${css}
|
||||
</style>`
|
||||
);
|
||||
html = html.replace(
|
||||
'<link rel="stylesheet" href="styles.css">',
|
||||
() => `<style>
|
||||
${css}
|
||||
</style>`
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
'<script src="i18n.js"></script>',
|
||||
() => `<script>
|
||||
${i18n}
|
||||
</script>`
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
'<script src="i18n.js"></script>',
|
||||
`<script>
|
||||
${i18n}
|
||||
</script>`
|
||||
);
|
||||
|
||||
const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i;
|
||||
if (scriptTagRegex.test(html)) {
|
||||
html = html.replace(
|
||||
scriptTagRegex,
|
||||
`<script>
|
||||
${app}
|
||||
</script>`
|
||||
);
|
||||
const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i;
|
||||
if (scriptTagRegex.test(html)) {
|
||||
html = html.replace(
|
||||
scriptTagRegex,
|
||||
() => `<script>
|
||||
${app}
|
||||
</script>`
|
||||
);
|
||||
} else {
|
||||
console.warn('未找到 app.js 脚本标签,未内联应用代码。');
|
||||
}
|
||||
|
||||
const logoDataUrl = loadLogoDataUrl();
|
||||
if (logoDataUrl) {
|
||||
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
|
||||
if (html.includes('</body>')) {
|
||||
html = html.replace('</body>', `${logoScript}\n</body>`);
|
||||
} else {
|
||||
html += `\n${logoScript}`;
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。');
|
||||
const logoDataUrl = loadLogoDataUrl();
|
||||
if (logoDataUrl) {
|
||||
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
|
||||
const closingBodyTag = '</body>';
|
||||
const closingBodyIndex = html.lastIndexOf(closingBodyTag);
|
||||
if (closingBodyIndex !== -1) {
|
||||
html = `${html.slice(0, closingBodyIndex)}${logoScript}\n${closingBodyTag}${html.slice(closingBodyIndex + closingBodyTag.length)}`;
|
||||
} else {
|
||||
html += `\n${logoScript}`;
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。');
|
||||
}
|
||||
|
||||
const outputPath = path.join(distDir, 'index.html');
|
||||
|
||||
38
i18n.js
38
i18n.js
@@ -430,6 +430,25 @@ const i18n = {
|
||||
'usage_stats.tokens_count': 'Token数量',
|
||||
'usage_stats.models': '模型统计',
|
||||
'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.failure': '失败',
|
||||
|
||||
@@ -982,6 +1001,25 @@ const i18n = {
|
||||
'usage_stats.tokens_count': 'Token Count',
|
||||
'usage_stats.models': 'Model Statistics',
|
||||
'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.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>
|
||||
</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>
|
||||
|
||||
<!-- 图表曲线选择 -->
|
||||
@@ -984,6 +995,18 @@
|
||||
</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>
|
||||
|
||||
<!-- API详细统计 -->
|
||||
@@ -1001,6 +1024,46 @@
|
||||
</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>
|
||||
|
||||
<!-- 配置管理 -->
|
||||
|
||||
@@ -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密钥的统计信息
|
||||
export async function getKeyStats(usageData = null) {
|
||||
try {
|
||||
@@ -83,6 +87,7 @@ export async function loadUsageStats(usageData = null) {
|
||||
usage = response?.usage || null;
|
||||
}
|
||||
this.currentUsageData = usage;
|
||||
this.ensureModelPriceState();
|
||||
|
||||
if (!usage) {
|
||||
throw new Error('usage payload missing');
|
||||
@@ -91,6 +96,8 @@ export async function loadUsageStats(usageData = null) {
|
||||
// 更新概览卡片
|
||||
this.updateUsageOverview(usage);
|
||||
this.updateChartLineSelectors(usage);
|
||||
this.renderModelPriceOptions(usage);
|
||||
this.renderSavedModelPrices();
|
||||
|
||||
// 读取当前图表周期
|
||||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||||
@@ -104,11 +111,16 @@ export async function loadUsageStats(usageData = null) {
|
||||
|
||||
// 更新API详细统计表格
|
||||
this.updateApiStatsTable(usage);
|
||||
this.updateCostSummaryAndChart(usage);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载使用统计失败:', error);
|
||||
this.currentUsageData = 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 => {
|
||||
@@ -346,6 +358,234 @@ export function collectUsageDetails() {
|
||||
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) {
|
||||
const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData);
|
||||
if (!details.length) {
|
||||
@@ -572,6 +812,231 @@ export function extractTotalTokens(detail) {
|
||||
}, 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() {
|
||||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||||
@@ -824,6 +1289,16 @@ export const usageModule = {
|
||||
getActiveChartLineSelections,
|
||||
collectUsageDetailsFromUsage,
|
||||
collectUsageDetails,
|
||||
migrateLegacyModelPrices,
|
||||
ensureModelPriceState,
|
||||
loadModelPricesFromStorage,
|
||||
persistModelPrices,
|
||||
renderModelPriceOptions,
|
||||
renderSavedModelPrices,
|
||||
prefillModelPriceInputs,
|
||||
normalizePriceValue,
|
||||
handleModelPriceSubmit,
|
||||
handleModelPriceReset,
|
||||
calculateTokenBreakdown,
|
||||
calculateRecentPerMinuteRates,
|
||||
createHourlyBucketMeta,
|
||||
@@ -835,6 +1310,12 @@ export const usageModule = {
|
||||
formatPerMinuteValue,
|
||||
formatDayLabel,
|
||||
extractTotalTokens,
|
||||
formatUsd,
|
||||
calculateCostData,
|
||||
setCostChartPlaceholder,
|
||||
destroyCostChart,
|
||||
initializeCostChart,
|
||||
updateCostSummaryAndChart,
|
||||
initializeCharts,
|
||||
initializeRequestsChart,
|
||||
initializeTokensChart,
|
||||
|
||||
82
styles.css
82
styles.css
@@ -3108,6 +3108,10 @@ input:checked+.slider:before {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.cost-summary-card .stat-icon {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.charts-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -3199,6 +3203,84 @@ input:checked+.slider:before {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user