mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
feat: 请求间隔散点图按模型区分颜色
- 后端 get_interval_timeline 接口返回数据添加 model 字段 - 前端散点图按模型分组显示不同颜色的数据点 - 横线统计信息支持按模型分别显示统计数据 - 管理员视图保持按用户分组,用户视图按模型分组 - 更新 mock 数据支持模型字段
This commit is contained in:
@@ -220,6 +220,7 @@ export interface IntervalTimelinePoint {
|
|||||||
x: string // ISO 时间字符串
|
x: string // ISO 时间字符串
|
||||||
y: number // 间隔分钟数
|
y: number // 间隔分钟数
|
||||||
user_id?: string // 用户 ID(仅 include_user_info=true 时存在)
|
user_id?: string // 用户 ID(仅 include_user_info=true 时存在)
|
||||||
|
model?: string // 模型名称
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntervalTimelineResponse {
|
export interface IntervalTimelineResponse {
|
||||||
@@ -227,6 +228,7 @@ export interface IntervalTimelineResponse {
|
|||||||
total_points: number
|
total_points: number
|
||||||
points: IntervalTimelinePoint[]
|
points: IntervalTimelinePoint[]
|
||||||
users?: Record<string, string> // user_id -> username 映射(仅 include_user_info=true 时存在)
|
users?: Record<string, string> // user_id -> username 映射(仅 include_user_info=true 时存在)
|
||||||
|
models?: string[] // 出现的模型列表
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cacheAnalysisApi = {
|
export const cacheAnalysisApi = {
|
||||||
|
|||||||
@@ -262,7 +262,8 @@ export const meApi = {
|
|||||||
}): Promise<{
|
}): Promise<{
|
||||||
analysis_period_hours: number
|
analysis_period_hours: number
|
||||||
total_points: number
|
total_points: number
|
||||||
points: Array<{ x: string; y: number }>
|
points: Array<{ x: string; y: number; model?: string }>
|
||||||
|
models?: string[]
|
||||||
}> {
|
}> {
|
||||||
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -6,9 +6,32 @@
|
|||||||
class="absolute top-2 right-2 bg-gray-800/90 text-gray-100 px-3 py-2 rounded-lg text-sm shadow-lg border border-gray-600"
|
class="absolute top-2 right-2 bg-gray-800/90 text-gray-100 px-3 py-2 rounded-lg text-sm shadow-lg border border-gray-600"
|
||||||
>
|
>
|
||||||
<div class="font-medium text-yellow-400">Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟</div>
|
<div class="font-medium text-yellow-400">Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟</div>
|
||||||
<div class="mt-1">
|
<!-- 单个 dataset 时显示简单统计 -->
|
||||||
<span class="text-green-400">{{ crosshairStats.belowCount }}</span> / {{ crosshairStats.totalCount }} 点在横线以下
|
<div v-if="crosshairStats.datasets.length === 1" class="mt-1">
|
||||||
<span class="ml-2 text-blue-400">({{ crosshairStats.belowPercent.toFixed(1) }}%)</span>
|
<span class="text-green-400">{{ crosshairStats.datasets[0].belowCount }}</span> / {{ crosshairStats.datasets[0].totalCount }} 点在横线以下
|
||||||
|
<span class="ml-2 text-blue-400">({{ crosshairStats.datasets[0].belowPercent.toFixed(1) }}%)</span>
|
||||||
|
</div>
|
||||||
|
<!-- 多个 dataset 时按模型分别显示 -->
|
||||||
|
<div v-else class="mt-1 space-y-0.5">
|
||||||
|
<div
|
||||||
|
v-for="ds in crosshairStats.datasets"
|
||||||
|
:key="ds.label"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
:style="{ backgroundColor: ds.color }"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-300 truncate max-w-[80px]">{{ ds.label }}:</span>
|
||||||
|
<span class="text-green-400">{{ ds.belowCount }}</span>/<span class="text-gray-400">{{ ds.totalCount }}</span>
|
||||||
|
<span class="text-blue-400">({{ ds.belowPercent.toFixed(0) }}%)</span>
|
||||||
|
</div>
|
||||||
|
<!-- 总计 -->
|
||||||
|
<div class="flex items-center gap-2 pt-1 border-t border-gray-600 mt-1">
|
||||||
|
<span class="text-gray-300">总计:</span>
|
||||||
|
<span class="text-green-400">{{ crosshairStats.totalBelowCount }}</span>/<span class="text-gray-400">{{ crosshairStats.totalCount }}</span>
|
||||||
|
<span class="text-blue-400">({{ crosshairStats.totalBelowPercent.toFixed(1) }}%)</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,13 +71,22 @@ interface Props {
|
|||||||
height?: number
|
height?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CrosshairStats {
|
interface DatasetStats {
|
||||||
yValue: number
|
label: string
|
||||||
|
color: string
|
||||||
belowCount: number
|
belowCount: number
|
||||||
totalCount: number
|
totalCount: number
|
||||||
belowPercent: number
|
belowPercent: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CrosshairStats {
|
||||||
|
yValue: number
|
||||||
|
datasets: DatasetStats[]
|
||||||
|
totalBelowCount: number
|
||||||
|
totalCount: number
|
||||||
|
totalBelowPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 300
|
height: 300
|
||||||
})
|
})
|
||||||
@@ -67,29 +99,47 @@ const crosshairY = ref<number | null>(null)
|
|||||||
const crosshairStats = computed<CrosshairStats | null>(() => {
|
const crosshairStats = computed<CrosshairStats | null>(() => {
|
||||||
if (crosshairY.value === null || !props.data.datasets) return null
|
if (crosshairY.value === null || !props.data.datasets) return null
|
||||||
|
|
||||||
|
const datasetStats: DatasetStats[] = []
|
||||||
|
let totalBelowCount = 0
|
||||||
let totalCount = 0
|
let totalCount = 0
|
||||||
let belowCount = 0
|
|
||||||
|
|
||||||
for (const dataset of props.data.datasets) {
|
for (const dataset of props.data.datasets) {
|
||||||
if (!dataset.data) continue
|
if (!dataset.data) continue
|
||||||
|
|
||||||
|
let belowCount = 0
|
||||||
|
let dsTotal = 0
|
||||||
|
|
||||||
for (const point of dataset.data) {
|
for (const point of dataset.data) {
|
||||||
const p = point as { x: string; y: number }
|
const p = point as { x: string; y: number }
|
||||||
if (typeof p.y === 'number') {
|
if (typeof p.y === 'number') {
|
||||||
|
dsTotal++
|
||||||
totalCount++
|
totalCount++
|
||||||
if (p.y <= crosshairY.value) {
|
if (p.y <= crosshairY.value) {
|
||||||
belowCount++
|
belowCount++
|
||||||
|
totalBelowCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dsTotal > 0) {
|
||||||
|
datasetStats.push({
|
||||||
|
label: dataset.label || 'Unknown',
|
||||||
|
color: (dataset.backgroundColor as string) || 'rgba(59, 130, 246, 0.7)',
|
||||||
|
belowCount,
|
||||||
|
totalCount: dsTotal,
|
||||||
|
belowPercent: (belowCount / dsTotal) * 100
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalCount === 0) return null
|
if (totalCount === 0) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
yValue: crosshairY.value,
|
yValue: crosshairY.value,
|
||||||
belowCount,
|
datasets: datasetStats,
|
||||||
|
totalBelowCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
belowPercent: (belowCount / totalCount) * 100
|
totalBelowPercent: (totalBelowCount / totalCount) * 100
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
<Card class="p-4">
|
<Card class="p-4">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<p class="text-sm font-semibold">{{ title }}</p>
|
<p class="text-sm font-semibold">{{ title }}</p>
|
||||||
<div v-if="hasMultipleUsers && userLegend.length > 0" class="flex items-center gap-2 flex-wrap justify-end text-[11px]">
|
<div v-if="legendItems.length > 0" class="flex items-center gap-2 flex-wrap justify-end text-[11px]">
|
||||||
<div
|
<div
|
||||||
v-for="user in userLegend"
|
v-for="item in legendItems"
|
||||||
:key="user.id"
|
:key="item.id"
|
||||||
class="flex items-center gap-1"
|
class="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-2.5 h-2.5 rounded-full"
|
class="w-2.5 h-2.5 rounded-full"
|
||||||
:style="{ backgroundColor: user.color }"
|
:style="{ backgroundColor: item.color }"
|
||||||
/>
|
/>
|
||||||
<span class="text-muted-foreground">{{ user.name }}</span>
|
<span class="text-muted-foreground">{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,8 +63,8 @@ onMounted(() => {
|
|||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 预定义的颜色列表(用于区分不同用户)
|
// 预定义的颜色列表(用于区分不同用户/模型)
|
||||||
const USER_COLORS = [
|
const COLORS = [
|
||||||
'rgba(59, 130, 246, 0.7)', // blue
|
'rgba(59, 130, 246, 0.7)', // blue
|
||||||
'rgba(236, 72, 153, 0.7)', // pink
|
'rgba(236, 72, 153, 0.7)', // pink
|
||||||
'rgba(34, 197, 94, 0.7)', // green
|
'rgba(34, 197, 94, 0.7)', // green
|
||||||
@@ -81,22 +81,59 @@ const hasData = computed(() =>
|
|||||||
timelineData.value && timelineData.value.points && timelineData.value.points.length > 0
|
timelineData.value && timelineData.value.points && timelineData.value.points.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasMultipleUsers = computed(() =>
|
// 判断是否有多个分组(管理员按用户分组,普通用户按模型分组)
|
||||||
props.isAdmin && timelineData.value?.users && Object.keys(timelineData.value.users).length > 1
|
const hasMultipleGroups = computed(() => {
|
||||||
)
|
if (props.isAdmin) {
|
||||||
|
// 管理员视图:按用户分组
|
||||||
// 用户图例
|
return timelineData.value?.users && Object.keys(timelineData.value.users).length > 1
|
||||||
const userLegend = computed(() => {
|
} else {
|
||||||
if (!props.isAdmin || !timelineData.value?.users) return []
|
// 用户视图:按模型分组
|
||||||
|
return timelineData.value?.models && timelineData.value.models.length > 1
|
||||||
const users = Object.entries(timelineData.value.users)
|
}
|
||||||
return users.map(([userId, username], index) => ({
|
|
||||||
id: userId,
|
|
||||||
name: username || userId.slice(0, 8),
|
|
||||||
color: USER_COLORS[index % USER_COLORS.length]
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 图例项(管理员显示用户,普通用户显示模型)
|
||||||
|
const legendItems = computed(() => {
|
||||||
|
if (props.isAdmin && timelineData.value?.users) {
|
||||||
|
// 管理员视图:显示用户图例
|
||||||
|
const users = Object.entries(timelineData.value.users)
|
||||||
|
if (users.length <= 1) return []
|
||||||
|
return users.map(([userId, username], index) => ({
|
||||||
|
id: userId,
|
||||||
|
name: username || userId.slice(0, 8),
|
||||||
|
color: COLORS[index % COLORS.length]
|
||||||
|
}))
|
||||||
|
} else if (timelineData.value?.models && timelineData.value.models.length > 1) {
|
||||||
|
// 用户视图:显示模型图例
|
||||||
|
return timelineData.value.models.map((model, index) => ({
|
||||||
|
id: model,
|
||||||
|
name: formatModelName(model),
|
||||||
|
color: COLORS[index % COLORS.length]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化模型名称,使其更简洁
|
||||||
|
function formatModelName(model: string): string {
|
||||||
|
// 常见的简化规则
|
||||||
|
if (model.includes('claude')) {
|
||||||
|
// claude-3-5-sonnet-20241022 -> Claude 3.5 Sonnet
|
||||||
|
const match = model.match(/claude-(\d+)-(\d+)?-?(\w+)?/i)
|
||||||
|
if (match) {
|
||||||
|
const major = match[1]
|
||||||
|
const minor = match[2]
|
||||||
|
const variant = match[3]
|
||||||
|
let name = `Claude ${major}`
|
||||||
|
if (minor) name += `.${minor}`
|
||||||
|
if (variant) name += ` ${variant.charAt(0).toUpperCase() + variant.slice(1)}`
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 其他模型保持原样但截断
|
||||||
|
return model.length > 20 ? model.slice(0, 17) + '...' : model
|
||||||
|
}
|
||||||
|
|
||||||
// 构建图表数据
|
// 构建图表数据
|
||||||
const chartData = computed<ChartData<'scatter'>>(() => {
|
const chartData = computed<ChartData<'scatter'>>(() => {
|
||||||
if (!timelineData.value?.points) {
|
if (!timelineData.value?.points) {
|
||||||
@@ -105,12 +142,12 @@ const chartData = computed<ChartData<'scatter'>>(() => {
|
|||||||
|
|
||||||
const points = timelineData.value.points
|
const points = timelineData.value.points
|
||||||
|
|
||||||
// 如果是管理员且有多个用户,按用户分组
|
// 管理员视图且有多个用户:按用户分组
|
||||||
if (props.isAdmin && timelineData.value.users && Object.keys(timelineData.value.users).length > 1) {
|
if (props.isAdmin && timelineData.value.users && Object.keys(timelineData.value.users).length > 1) {
|
||||||
const userIds = Object.keys(timelineData.value.users)
|
const userIds = Object.keys(timelineData.value.users)
|
||||||
const userColorMap: Record<string, string> = {}
|
const userColorMap: Record<string, string> = {}
|
||||||
userIds.forEach((userId, index) => {
|
userIds.forEach((userId, index) => {
|
||||||
userColorMap[userId] = USER_COLORS[index % USER_COLORS.length]
|
userColorMap[userId] = COLORS[index % COLORS.length]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 按用户分组数据
|
// 按用户分组数据
|
||||||
@@ -136,7 +173,38 @@ const chartData = computed<ChartData<'scatter'>>(() => {
|
|||||||
return { datasets }
|
return { datasets }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单用户或用户视图:使用主题色
|
// 用户视图且有多个模型:按模型分组
|
||||||
|
if (!props.isAdmin && timelineData.value.models && timelineData.value.models.length > 1) {
|
||||||
|
const models = timelineData.value.models
|
||||||
|
const modelColorMap: Record<string, string> = {}
|
||||||
|
models.forEach((model, index) => {
|
||||||
|
modelColorMap[model] = COLORS[index % COLORS.length]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按模型分组数据
|
||||||
|
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
|
||||||
|
for (const point of points) {
|
||||||
|
const model = point.model || 'unknown'
|
||||||
|
if (!groupedData[model]) {
|
||||||
|
groupedData[model] = []
|
||||||
|
}
|
||||||
|
groupedData[model].push({ x: point.x, y: point.y })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建每个模型的 dataset
|
||||||
|
const datasets = Object.entries(groupedData).map(([model, data]) => ({
|
||||||
|
label: formatModelName(model),
|
||||||
|
data,
|
||||||
|
backgroundColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.6)',
|
||||||
|
borderColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.8)',
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { datasets }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单用户或单模型:使用主题色
|
||||||
return {
|
return {
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: '请求间隔',
|
label: '请求间隔',
|
||||||
@@ -160,7 +228,7 @@ const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
|
|||||||
const point = context.raw as { x: string; y: number; _originalY?: number }
|
const point = context.raw as { x: string; y: number; _originalY?: number }
|
||||||
const realY = point._originalY ?? point.y
|
const realY = point._originalY ?? point.y
|
||||||
const datasetLabel = context.dataset.label || ''
|
const datasetLabel = context.dataset.label || ''
|
||||||
if (props.isAdmin && hasMultipleUsers.value) {
|
if (hasMultipleGroups.value) {
|
||||||
return `${datasetLabel}: ${realY.toFixed(1)} 分钟`
|
return `${datasetLabel}: ${realY.toFixed(1)} 分钟`
|
||||||
}
|
}
|
||||||
return `间隔: ${realY.toFixed(1)} 分钟`
|
return `间隔: ${realY.toFixed(1)} 分钟`
|
||||||
|
|||||||
@@ -2166,7 +2166,7 @@ function generateIntervalTimelineData(
|
|||||||
) {
|
) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const startTime = now - hours * 60 * 60 * 1000
|
const startTime = now - hours * 60 * 60 * 1000
|
||||||
const points: Array<{ x: string; y: number; user_id?: string }> = []
|
const points: Array<{ x: string; y: number; user_id?: string; model?: string }> = []
|
||||||
|
|
||||||
// 用户列表(用于管理员视图)
|
// 用户列表(用于管理员视图)
|
||||||
const users = [
|
const users = [
|
||||||
@@ -2176,6 +2176,14 @@ function generateIntervalTimelineData(
|
|||||||
{ id: 'demo-user-uuid-0004', username: 'Bob Zhang' }
|
{ id: 'demo-user-uuid-0004', username: 'Bob Zhang' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 模型列表(用于按模型区分颜色)
|
||||||
|
const models = [
|
||||||
|
'claude-sonnet-4-20250514',
|
||||||
|
'claude-3-5-sonnet-20241022',
|
||||||
|
'claude-3-5-haiku-20241022',
|
||||||
|
'claude-opus-4-20250514'
|
||||||
|
]
|
||||||
|
|
||||||
// 生成模拟的请求间隔数据
|
// 生成模拟的请求间隔数据
|
||||||
// 间隔时间分布:大部分在 0-10 分钟,少量在 10-60 分钟,极少数在 60-120 分钟
|
// 间隔时间分布:大部分在 0-10 分钟,少量在 10-60 分钟,极少数在 60-120 分钟
|
||||||
const pointCount = Math.min(limit, Math.floor(hours * 80)) // 每小时约 80 个数据点
|
const pointCount = Math.min(limit, Math.floor(hours * 80)) // 每小时约 80 个数据点
|
||||||
@@ -2211,9 +2219,10 @@ function generateIntervalTimelineData(
|
|||||||
// 确保间隔不超过 120 分钟
|
// 确保间隔不超过 120 分钟
|
||||||
interval = Math.min(interval, 120)
|
interval = Math.min(interval, 120)
|
||||||
|
|
||||||
const point: { x: string; y: number; user_id?: string } = {
|
const point: { x: string; y: number; user_id?: string; model?: string } = {
|
||||||
x: new Date(currentTime).toISOString(),
|
x: new Date(currentTime).toISOString(),
|
||||||
y: Math.round(interval * 100) / 100
|
y: Math.round(interval * 100) / 100,
|
||||||
|
model: models[Math.floor(Math.random() * models.length)]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeUserInfo) {
|
if (includeUserInfo) {
|
||||||
@@ -2231,15 +2240,20 @@ function generateIntervalTimelineData(
|
|||||||
// 按时间排序
|
// 按时间排序
|
||||||
points.sort((a, b) => new Date(a.x).getTime() - new Date(b.x).getTime())
|
points.sort((a, b) => new Date(a.x).getTime() - new Date(b.x).getTime())
|
||||||
|
|
||||||
|
// 收集出现的模型
|
||||||
|
const usedModels = [...new Set(points.map(p => p.model).filter(Boolean))] as string[]
|
||||||
|
|
||||||
const response: {
|
const response: {
|
||||||
analysis_period_hours: number
|
analysis_period_hours: number
|
||||||
total_points: number
|
total_points: number
|
||||||
points: typeof points
|
points: typeof points
|
||||||
users?: Record<string, string>
|
users?: Record<string, string>
|
||||||
|
models?: string[]
|
||||||
} = {
|
} = {
|
||||||
analysis_period_hours: hours,
|
analysis_period_hours: hours,
|
||||||
total_points: points.length,
|
total_points: points.length,
|
||||||
points
|
points,
|
||||||
|
models: usedModels
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeUserInfo) {
|
if (includeUserInfo) {
|
||||||
|
|||||||
@@ -1746,7 +1746,7 @@ class UsageService:
|
|||||||
include_user_info: 是否包含用户信息(用于管理员多用户视图)
|
include_user_info: 是否包含用户信息(用于管理员多用户视图)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
包含时间线数据点的字典
|
包含时间线数据点的字典,每个数据点包含 model 字段用于按模型区分颜色
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
@@ -1764,6 +1764,7 @@ class UsageService:
|
|||||||
SELECT
|
SELECT
|
||||||
u.created_at,
|
u.created_at,
|
||||||
u.user_id,
|
u.user_id,
|
||||||
|
u.model,
|
||||||
usr.username,
|
usr.username,
|
||||||
LAG(u.created_at) OVER (
|
LAG(u.created_at) OVER (
|
||||||
PARTITION BY u.user_id
|
PARTITION BY u.user_id
|
||||||
@@ -1780,6 +1781,7 @@ class UsageService:
|
|||||||
SELECT
|
SELECT
|
||||||
created_at,
|
created_at,
|
||||||
user_id,
|
user_id,
|
||||||
|
model,
|
||||||
username,
|
username,
|
||||||
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes,
|
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes,
|
||||||
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) as rn
|
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) as rn
|
||||||
@@ -1804,6 +1806,7 @@ class UsageService:
|
|||||||
SELECT
|
SELECT
|
||||||
fi.created_at,
|
fi.created_at,
|
||||||
fi.user_id,
|
fi.user_id,
|
||||||
|
fi.model,
|
||||||
fi.username,
|
fi.username,
|
||||||
fi.interval_minutes
|
fi.interval_minutes
|
||||||
FROM filtered_intervals fi
|
FROM filtered_intervals fi
|
||||||
@@ -1812,12 +1815,13 @@ class UsageService:
|
|||||||
ORDER BY fi.created_at
|
ORDER BY fi.created_at
|
||||||
""")
|
""")
|
||||||
else:
|
else:
|
||||||
# 普通视图:只返回时间和间隔
|
# 普通视图:返回时间、间隔和模型信息
|
||||||
sql = text(f"""
|
sql = text(f"""
|
||||||
WITH request_intervals AS (
|
WITH request_intervals AS (
|
||||||
SELECT
|
SELECT
|
||||||
u.created_at,
|
u.created_at,
|
||||||
u.user_id,
|
u.user_id,
|
||||||
|
u.model,
|
||||||
LAG(u.created_at) OVER (
|
LAG(u.created_at) OVER (
|
||||||
PARTITION BY u.user_id
|
PARTITION BY u.user_id
|
||||||
ORDER BY u.created_at
|
ORDER BY u.created_at
|
||||||
@@ -1830,6 +1834,7 @@ class UsageService:
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
created_at,
|
created_at,
|
||||||
|
model,
|
||||||
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes
|
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes
|
||||||
FROM request_intervals
|
FROM request_intervals
|
||||||
WHERE prev_request_at IS NOT NULL
|
WHERE prev_request_at IS NOT NULL
|
||||||
@@ -1848,24 +1853,33 @@ class UsageService:
|
|||||||
# 转换为时间线数据点
|
# 转换为时间线数据点
|
||||||
points = []
|
points = []
|
||||||
users_map: Dict[str, str] = {} # user_id -> username
|
users_map: Dict[str, str] = {} # user_id -> username
|
||||||
|
models_set: set = set() # 收集所有出现的模型
|
||||||
|
|
||||||
if include_user_info and not user_id:
|
if include_user_info and not user_id:
|
||||||
for row in rows:
|
for row in rows:
|
||||||
created_at, row_user_id, username, interval_minutes = row
|
created_at, row_user_id, model, username, interval_minutes = row
|
||||||
points.append({
|
point_data: Dict[str, Any] = {
|
||||||
"x": created_at.isoformat(),
|
"x": created_at.isoformat(),
|
||||||
"y": round(float(interval_minutes), 2),
|
"y": round(float(interval_minutes), 2),
|
||||||
"user_id": str(row_user_id),
|
"user_id": str(row_user_id),
|
||||||
})
|
}
|
||||||
|
if model:
|
||||||
|
point_data["model"] = model
|
||||||
|
models_set.add(model)
|
||||||
|
points.append(point_data)
|
||||||
if row_user_id and username:
|
if row_user_id and username:
|
||||||
users_map[str(row_user_id)] = username
|
users_map[str(row_user_id)] = username
|
||||||
else:
|
else:
|
||||||
for row in rows:
|
for row in rows:
|
||||||
created_at, interval_minutes = row
|
created_at, model, interval_minutes = row
|
||||||
points.append({
|
point_data = {
|
||||||
"x": created_at.isoformat(),
|
"x": created_at.isoformat(),
|
||||||
"y": round(float(interval_minutes), 2)
|
"y": round(float(interval_minutes), 2)
|
||||||
})
|
}
|
||||||
|
if model:
|
||||||
|
point_data["model"] = model
|
||||||
|
models_set.add(model)
|
||||||
|
points.append(point_data)
|
||||||
|
|
||||||
response: Dict[str, Any] = {
|
response: Dict[str, Any] = {
|
||||||
"analysis_period_hours": hours,
|
"analysis_period_hours": hours,
|
||||||
@@ -1876,4 +1890,8 @@ class UsageService:
|
|||||||
if include_user_info and not user_id:
|
if include_user_info and not user_id:
|
||||||
response["users"] = users_map
|
response["users"] = users_map
|
||||||
|
|
||||||
|
# 如果有模型信息,返回模型列表
|
||||||
|
if models_set:
|
||||||
|
response["models"] = sorted(models_set)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
Reference in New Issue
Block a user