Compare commits

...

3 Commits

Author SHA1 Message Date
shaw
76d242e024 refactor(frontend): 复用 TokenUsageTrend 组件优化用户 Dashboard 图表
用户 Dashboard 的 Token 使用趋势图表现在显示 Input/Output/Cache 三种类型,
并在 Tooltip 中显示 Actual 和 Standard 价格,与管理员页面保持一致。
2026-02-06 20:18:38 +08:00
shaw
260c152166 fix(frontend): 修复重启后健康检查接口路径错误
将 /api/health 改为 /health,与后端实际注册的路由一致
2026-02-06 19:53:39 +08:00
shaw
9f4c1ef9f9 fix(ops): 添加 token 相关字段白名单避免误脱敏
在敏感字段检测中添加白名单,排除 API 参数和用量统计字段:
- max_tokens, max_completion_tokens, max_output_tokens
- completion_tokens, prompt_tokens, total_tokens
- input_tokens, output_tokens
- cache_creation_input_tokens, cache_read_input_tokens

这些字段名虽然包含 "token" 但只是数值参数,不应被脱敏处理。
2026-02-06 19:47:14 +08:00
3 changed files with 14 additions and 55 deletions

View File

@@ -424,6 +424,16 @@ func isSensitiveKey(key string) bool {
return false return false
} }
// Whitelist: known non-sensitive fields that contain sensitive substrings
// (e.g., "max_tokens" contains "token" but is just an API parameter).
switch k {
case "max_tokens", "max_completion_tokens", "max_output_tokens",
"completion_tokens", "prompt_tokens", "total_tokens",
"input_tokens", "output_tokens",
"cache_creation_input_tokens", "cache_read_input_tokens":
return false
}
// Exact matches (common credential fields). // Exact matches (common credential fields).
switch k { switch k {
case "authorization", case "authorization",

View File

@@ -491,7 +491,7 @@ async function checkServiceAndReload() {
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
try { try {
const response = await fetch('/api/health', { const response = await fetch('/health', {
method: 'GET', method: 'GET',
cache: 'no-cache' cache: 'no-cache'
}) })

View File

@@ -55,16 +55,7 @@
</div> </div>
<!-- Token Usage Trend Chart --> <!-- Token Usage Trend Chart -->
<div class="card relative overflow-hidden p-4"> <TokenUsageTrend :trend-data="trend" :loading="loading" />
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
<LoadingSpinner size="md" />
</div>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.tokenUsageTrend') }}</h3>
<div class="h-48">
<Line v-if="trendData" :data="trendData" :options="lineOptions" />
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -75,7 +66,8 @@ import { useI18n } from 'vue-i18n'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue' import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import { Line, Doughnut } from 'vue-chartjs' import { Doughnut } from 'vue-chartjs'
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import type { TrendDataPoint, ModelStat } from '@/types' import type { TrendDataPoint, ModelStat } from '@/types'
import { formatCostFixed as formatCost, formatNumberLocaleString as formatNumber, formatTokensK as formatTokens } from '@/utils/format' import { formatCostFixed as formatCost, formatNumberLocaleString as formatNumber, formatTokensK as formatTokens } from '@/utils/format'
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler } from 'chart.js' import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler } from 'chart.js'
@@ -93,28 +85,6 @@ const modelData = computed(() => !props.models?.length ? null : {
}] }]
}) })
const trendData = computed(() => !props.trend?.length ? null : {
labels: props.trend.map((d: TrendDataPoint) => d.date),
datasets: [
{
label: t('dashboard.input'),
data: props.trend.map((d: TrendDataPoint) => d.input_tokens),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
fill: true
},
{
label: t('dashboard.output'),
data: props.trend.map((d: TrendDataPoint) => d.output_tokens),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.3,
fill: true
}
]
})
const doughnutOptions = { const doughnutOptions = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -127,25 +97,4 @@ const doughnutOptions = {
} }
} }
} }
const lineOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' as const },
tooltip: {
callbacks: {
label: (context: any) => `${context.dataset.label}: ${formatTokens(context.parsed.y)} tokens`
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value: any) => formatTokens(value)
}
}
}
}
</script> </script>