feat: add ai cost tracking foundation

This commit is contained in:
2026-07-31 16:24:35 +08:00
parent ec9f8a015a
commit a84650bb9e
14 changed files with 327 additions and 2 deletions

View File

@@ -136,6 +136,14 @@ const modelForm = reactive({
extraParams: "",
remark: "",
timeoutSecond: 30,
inputPricePer1k: null as number | null,
outputPricePer1k: null as number | null,
currency: "CNY",
usageScenarios: "",
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowDeepChat: 1,
});
const quickModelForm = reactive({
@@ -212,6 +220,9 @@ const nullableModelFields = [
"responseFormat",
"extraParams",
"remark",
"inputPricePer1k",
"outputPricePer1k",
"usageScenarios",
] as const;
const systemSettingValues = reactive<Record<string, SystemSettingValue>>({});
@@ -289,6 +300,7 @@ async function loadDashboard() {
async function refreshStorage() { storageLoading.value = true; try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); } finally { storageLoading.value = false; } }
function formatBytes(value: unknown) { if (typeof value !== "number") return "无法统计"; const units = ["B", "KB", "MB", "GB", "TB"]; let size = value, i = 0; while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1; } return `${size.toFixed(i ? 2 : 0)} ${units[i]}`; }
function formatMoney(value?: number | null, currency = "CNY") { if (typeof value !== "number") return "-"; return `${currency || "CNY"} ${value.toFixed(6)}`; }
async function cleanupRetrievalLogs() { if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期"); const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value); await ElMessageBox.confirm(`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`, "确认清理", { type: "warning" }); const result = await api.cleanupRetrievalLogs(cleanupBefore.value); ElMessage.success(`已清理 ${result.deleted} 条日志`); await loadCurrentMenu(); }
async function saveRetention() { await api.saveRetrievalRetention(retentionDays.value); ElMessage.success(retentionDays.value == null ? "已设为永久保留" : `已设为保留 ${retentionDays.value}`); await loadCurrentMenu(); }
@@ -629,6 +641,14 @@ async function quickAddModel() {
extraParams: null,
remark: "快速添加",
timeoutSecond: 30,
inputPricePer1k: null,
outputPricePer1k: null,
currency: "CNY",
usageScenarios: "正式对话",
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowDeepChat: 1,
});
ElMessage.success(`${provider.label} 模型已新增`);
quickModelForm.apiKey = "";
@@ -715,6 +735,14 @@ function editModel(row: ModelItem) {
extraParams: row.extraParams ?? "",
remark: row.remark ?? "",
timeoutSecond: row.timeoutSecond,
inputPricePer1k: row.inputPricePer1k ?? null,
outputPricePer1k: row.outputPricePer1k ?? null,
currency: row.currency || "CNY",
usageScenarios: row.usageScenarios ?? "",
allowSummary: row.allowSummary ?? 1,
allowReport: row.allowReport ?? 1,
allowFixedInfo: row.allowFixedInfo ?? 1,
allowDeepChat: row.allowDeepChat ?? 1,
});
}
@@ -742,6 +770,14 @@ function resetModelForm() {
extraParams: "",
remark: "",
timeoutSecond: 30,
inputPricePer1k: null,
outputPricePer1k: null,
currency: "CNY",
usageScenarios: "",
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowDeepChat: 1,
});
}
@@ -1001,6 +1037,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div class="stat"><span>输入 Token</span><strong>{{ stats?.inputToken ?? 0 }}</strong></div>
<div class="stat"><span>输出 Token</span><strong>{{ stats?.outputToken ?? 0 }}</strong></div>
<div class="stat"><span> Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong></div>
<div class="stat"><span>估算成本</span><strong>{{ formatMoney(stats?.estimatedCost, stats?.costCurrency || 'CNY') }}</strong></div>
</div>
<section class="storage-panel" v-loading="storageLoading"><div class="storage-panel-head"><div><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计;无权限或不支持时显示“无法统计”。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div><div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div><small>最后统计{{ storageStats?.createdAt || '尚未统计' }}</small></section>
</template>
@@ -1292,7 +1329,27 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-form-item label="超时秒数">
<el-input-number v-model="modelForm.timeoutSecond" :min="1" :max="300" />
</el-form-item>
<el-form-item label="输入单价/千Token">
<el-input-number v-model="modelForm.inputPricePer1k" :min="0" :precision="6" :step="0.001" placeholder="不填则不估算成本" />
</el-form-item>
<el-form-item label="输出单价/千Token">
<el-input-number v-model="modelForm.outputPricePer1k" :min="0" :precision="6" :step="0.001" placeholder="不填则不估算成本" />
</el-form-item>
<el-form-item label="币种">
<el-input v-model="modelForm.currency" placeholder="CNY / USD" maxlength="10" />
</el-form-item>
</div>
<el-form-item label="适用场景">
<el-input v-model="modelForm.usageScenarios" placeholder="例如:正式对话、摘要、固定信息、报告批处理" />
</el-form-item>
<el-form-item label="可用能力">
<div class="entitlement-capabilities">
<el-checkbox v-model="modelForm.allowFixedInfo" :true-value="1" :false-value="0">固定信息</el-checkbox>
<el-checkbox v-model="modelForm.allowDeepChat" :true-value="1" :false-value="0">深度对话</el-checkbox>
<el-checkbox v-model="modelForm.allowSummary" :true-value="1" :false-value="0">摘要沉淀</el-checkbox>
<el-checkbox v-model="modelForm.allowReport" :true-value="1" :false-value="0">周期报告</el-checkbox>
</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="modelForm.remark" placeholder="用途、额度、注意事项等" />
</el-form-item>
@@ -1306,6 +1363,10 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-table-column prop="provider" label="Provider" />
<el-table-column prop="apiType" label="协议" width="170" />
<el-table-column prop="modelName" label="模型" />
<el-table-column label="估算单价" width="180">
<template #default="{ row }">{{ row.currency || 'CNY' }} {{ row.inputPricePer1k ?? '-' }}/{{ row.outputPricePer1k ?? '-' }}</template>
</el-table-column>
<el-table-column prop="usageScenarios" label="适用场景" min-width="160" show-overflow-tooltip />
<el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="authType" label="鉴权" width="110" />
<el-table-column prop="enabled" label="启用" width="90" />
@@ -1521,6 +1582,9 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template>
</el-table-column>
<el-table-column prop="totalToken" label="Token" width="90" />
<el-table-column label="估算成本" width="130">
<template #default="{ row }">{{ formatMoney(row.estimatedCost, row.currency || 'CNY') }}</template>
</el-table-column>
<el-table-column prop="costMs" label="耗时(ms)" width="100" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="errorMessage" label="错误" min-width="220" show-overflow-tooltip />
@@ -1726,6 +1790,10 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<span>输入 Token{{ selectedAiLog.inputToken || '-' }}</span>
<span>输出 Token{{ selectedAiLog.outputToken || '-' }}</span>
<span> Token{{ selectedAiLog.totalToken || '-' }}</span>
<span>估算成本{{ formatMoney(selectedAiLog.estimatedCost, selectedAiLog.currency || 'CNY') }}</span>
<span>问题类型{{ selectedAiLog.questionType || '-' }}</span>
<span>知识命中{{ selectedAiLog.knowledgeHit ? '是' : '否' }}</span>
<span>路由原因{{ selectedAiLog.routeReason || '-' }}</span>
<span>耗时{{ selectedAiLog.costMs || '-' }}ms</span>
<span>时间{{ selectedAiLog.createdAt }}</span>
</section>