mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
feat: revamp analytics dashboards and harden database migrations
Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend. Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users. Include the current documentation cleanup and regression coverage.
This commit is contained in:
@@ -192,4 +192,59 @@ describe('apiClient auth state change event', () => {
|
||||
rawClient.defaults.adapter = previousAdapter
|
||||
}
|
||||
})
|
||||
|
||||
it('authenticates user and admin health while public status stays anonymous', async () => {
|
||||
const rawClient = apiClient['client']
|
||||
const previousAdapter = rawClient.defaults.adapter
|
||||
const requests: InternalAxiosRequestConfig[] = []
|
||||
|
||||
rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
|
||||
requests.push(config)
|
||||
return { data: {}, status: 200, statusText: 'OK', headers: {}, config }
|
||||
}) as AxiosAdapter
|
||||
|
||||
try {
|
||||
apiClient.setToken('health-access-token')
|
||||
await apiClient.get('/api/users/me/health/v2/summary')
|
||||
await apiClient.get('/api/admin/endpoints/health/v2/objects')
|
||||
await apiClient.get('/api/public/health/v2/summary')
|
||||
await apiClient.get('/_gateway/health')
|
||||
|
||||
expect(requests.map(request => request.headers.Authorization)).toEqual([
|
||||
'Bearer health-access-token',
|
||||
'Bearer health-access-token',
|
||||
undefined,
|
||||
undefined,
|
||||
])
|
||||
expect(requests[0].headers['X-Client-Device-Id']).toBeTruthy()
|
||||
} finally {
|
||||
rawClient.defaults.adapter = previousAdapter
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps publication and protected JSON routes authenticated despite public-looking names or query strings', async () => {
|
||||
const rawClient = apiClient['client']
|
||||
const previousAdapter = rawClient.defaults.adapter
|
||||
const requests: InternalAxiosRequestConfig[] = []
|
||||
rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
|
||||
requests.push(config)
|
||||
return { data: {}, status: 200, statusText: 'OK', headers: {}, config }
|
||||
}) as AxiosAdapter
|
||||
try {
|
||||
apiClient.setToken('publication-access-token')
|
||||
await apiClient.get('/api/admin/endpoints/health/v2/publication')
|
||||
await apiClient.put('/api/admin/endpoints/health/v2/publication', { enabled: false, objects: [] })
|
||||
await apiClient.get('/api/admin/usage/records?search=/public/health.json')
|
||||
await apiClient.get('/api/admin/reports.json')
|
||||
await apiClient.get('/api/publicity')
|
||||
await apiClient.get('/api/public/health/v2/summary?window=1h')
|
||||
expect(requests.map(request => request.headers.Authorization)).toEqual([
|
||||
'Bearer publication-access-token', 'Bearer publication-access-token',
|
||||
'Bearer publication-access-token', 'Bearer publication-access-token',
|
||||
'Bearer publication-access-token', undefined,
|
||||
])
|
||||
} finally {
|
||||
rawClient.defaults.adapter = previousAdapter
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: { get: getMock },
|
||||
}))
|
||||
|
||||
import { overviewApi, type OverviewQuery } from '@/api/overview'
|
||||
|
||||
describe('overview dashboard API contract', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
})
|
||||
|
||||
it('loads the compact flat dashboard snapshot with timezone and cancellation', async () => {
|
||||
const signal = new AbortController().signal
|
||||
const response = { stats_since: '2026-09-19T00:00:00Z', today: { request_count: 3 }, total: { request_count: 42 } }
|
||||
getMock.mockResolvedValueOnce({ data: response })
|
||||
expect(await overviewApi.dashboardSummary('Asia/Shanghai', signal)).toBe(response)
|
||||
expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard/summary', { params: { timezone: 'Asia/Shanghai' }, signal })
|
||||
})
|
||||
|
||||
it.each([true, false, null])('loads today and lifetime totals with history_complete=%s using only the timezone', async (historyComplete) => {
|
||||
const signal = new AbortController().signal
|
||||
const response = {
|
||||
today: { data: { request_count: 2 } },
|
||||
total: { data: { request_count: 42 } },
|
||||
history_complete: historyComplete,
|
||||
}
|
||||
getMock.mockResolvedValueOnce({ data: response })
|
||||
|
||||
const result = await overviewApi.dashboard('America/New_York', signal)
|
||||
|
||||
expect(getMock).toHaveBeenCalledTimes(1)
|
||||
expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard', {
|
||||
params: { timezone: 'America/New_York' },
|
||||
signal,
|
||||
})
|
||||
expect(result).toBe(response)
|
||||
})
|
||||
|
||||
it.each([{ status: 'pending' }, { status: 'ready', total: { data: { request_count: 42 } }, history_complete: null, stale: true }])(
|
||||
'loads independently computed lifetime totals: $status', async (response) => {
|
||||
const signal = new AbortController().signal
|
||||
getMock.mockResolvedValueOnce({ data: response })
|
||||
|
||||
expect(await overviewApi.dashboardTotal('Asia/Shanghai', signal)).toBe(response)
|
||||
expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard/total', {
|
||||
params: { timezone: 'Asia/Shanghai' }, signal,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('loads full-site daily charts without leaking filters, pagination, or hourly granularity', async () => {
|
||||
const signal = new AbortController().signal
|
||||
const query: OverviewQuery = {
|
||||
from: '2026-03-08T05:12:34.000Z',
|
||||
to: '2026-03-09T04:00:00.000Z',
|
||||
timezone: 'America/New_York',
|
||||
user_id: 'employee-1',
|
||||
api_key_id: 'key-1',
|
||||
credential_owner_id: 'owner-1',
|
||||
attribution_kind: 'employee',
|
||||
provider_id: 'provider-1',
|
||||
model: 'model-1',
|
||||
request_type: 'chat',
|
||||
api_format: 'openai',
|
||||
endpoint_kind: 'chat',
|
||||
is_stream: false,
|
||||
has_format_conversion: false,
|
||||
slow_threshold_ms: 1000,
|
||||
status: 'success',
|
||||
search: 'employee',
|
||||
account_status: 'active',
|
||||
usage_status: 'active',
|
||||
amount_basis: 'rated',
|
||||
group_by: 'provider',
|
||||
sort: 'request_count',
|
||||
order: 'asc',
|
||||
granularity: 'hour',
|
||||
limit: 1,
|
||||
offset: 10,
|
||||
}
|
||||
const response = {
|
||||
meta: { read_revision: 'dashboard-chart-revision' },
|
||||
data: {
|
||||
summary: { request_count: 5 },
|
||||
series: [{ bucket_start: '2026-03-08T05:00:00.000Z' }],
|
||||
models: [{ id: 'model-1', bucket_start: '2026-03-08T05:00:00.000Z' }],
|
||||
providers: [{ id: 'provider-1' }, { id: 'provider-2' }, { id: null }],
|
||||
},
|
||||
}
|
||||
getMock.mockResolvedValueOnce({ data: response })
|
||||
|
||||
const result = await overviewApi.dashboardCharts(query, signal)
|
||||
|
||||
expect(getMock).toHaveBeenCalledTimes(1)
|
||||
expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard/charts', {
|
||||
params: {
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
timezone: query.timezone,
|
||||
granularity: 'day',
|
||||
},
|
||||
signal,
|
||||
})
|
||||
expect(result).toBe(response)
|
||||
})
|
||||
})
|
||||
@@ -94,12 +94,13 @@ export interface RefundCompleteRequest {
|
||||
|
||||
export const adminWalletApi = {
|
||||
async listWallets(params?: {
|
||||
user_id?: string
|
||||
status?: string
|
||||
owner_type?: 'user' | 'api_key'
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AdminWalletListResponse> {
|
||||
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', { params })
|
||||
}, signal?: AbortSignal): Promise<AdminWalletListResponse> {
|
||||
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', { params, signal })
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -183,11 +184,12 @@ export const adminWalletApi = {
|
||||
|
||||
async getWalletTransactions(
|
||||
walletId: string,
|
||||
params?: { limit?: number; offset?: number }
|
||||
params?: { limit?: number; offset?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<AdminWalletTransactionsResponse> {
|
||||
const response = await apiClient.get<AdminWalletTransactionsResponse>(
|
||||
`/api/admin/wallets/${walletId}/transactions`,
|
||||
{ params }
|
||||
{ params, signal }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -50,6 +50,15 @@ export interface UpdateAnnouncementRequest {
|
||||
}
|
||||
|
||||
export const announcementApi = {
|
||||
async getUserAnnouncements(params?: {
|
||||
unread_only?: boolean
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AnnouncementListResponse> {
|
||||
const response = await apiClient.get<AnnouncementListResponse>('/api/announcements/users/me', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取公告列表
|
||||
async getAnnouncements(params?: {
|
||||
active_only?: boolean
|
||||
|
||||
+14
-11
@@ -39,15 +39,19 @@ function loadMockRuntime(): Promise<MockRuntime> {
|
||||
/**
|
||||
* 判断请求是否为公共端点
|
||||
*/
|
||||
function requestPath(url?: string): string {
|
||||
if (!url) return ''
|
||||
try { return new URL(url, 'http://aether.local').pathname } catch { return '' }
|
||||
}
|
||||
|
||||
function isPublicEndpoint(url?: string, method?: string): boolean {
|
||||
if (!url) return false
|
||||
const path = requestPath(url)
|
||||
const isHealthCheck = ['/health', '/v1/health', '/_gateway/health'].includes(path) &&
|
||||
method?.toLowerCase() === 'get'
|
||||
|
||||
const isHealthCheck = url.includes('/health') &&
|
||||
method?.toLowerCase() === 'get' &&
|
||||
!url.includes('/api/admin')
|
||||
|
||||
return url.includes('/public') ||
|
||||
url.includes('.json') ||
|
||||
return path === '/api/public' || path.startsWith('/api/public/') ||
|
||||
path === '/public' || path.startsWith('/public/') ||
|
||||
(!path.startsWith('/api/') && path.endsWith('.json')) ||
|
||||
isHealthCheck
|
||||
}
|
||||
|
||||
@@ -55,12 +59,11 @@ function isPublicEndpoint(url?: string, method?: string): boolean {
|
||||
* 判断是否为认证相关请求
|
||||
*/
|
||||
function isAuthRequest(url?: string): boolean {
|
||||
return url?.includes('/auth/login') || url?.includes('/auth/refresh') || url?.includes('/auth/logout') || false
|
||||
return ['/api/auth/login', '/api/auth/refresh', '/api/auth/logout'].includes(requestPath(url))
|
||||
}
|
||||
|
||||
function isProtectedOperationalEndpoint(url?: string): boolean {
|
||||
if (!url) return false
|
||||
const path = url.split('?', 1)[0]
|
||||
const path = requestPath(url)
|
||||
return path === '/_gateway/metrics' ||
|
||||
path.startsWith('/_gateway/audit/') ||
|
||||
path.startsWith('/_gateway/async-tasks/')
|
||||
@@ -148,7 +151,7 @@ class ApiClient {
|
||||
// 请求拦截器 - 仅处理认证
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const carriesSessionCredentials = config.url?.includes('/api/') ||
|
||||
const carriesSessionCredentials = requestPath(config.url).startsWith('/api/') ||
|
||||
isProtectedOperationalEndpoint(config.url)
|
||||
|
||||
if (carriesSessionCredentials) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import client from '../client'
|
||||
|
||||
export type HealthObjectKind = 'api_format' | 'model' | 'provider'
|
||||
export type PublicHealthObjectKind = Exclude<HealthObjectKind, 'provider'>
|
||||
export type ServiceHealthStatus = 'healthy' | 'degraded' | 'unavailable' | 'unknown'
|
||||
export type HealthWindow = '1h' | '6h' | '24h' | '72h'
|
||||
|
||||
export interface HealthRatio {
|
||||
numerator: number
|
||||
denominator: number
|
||||
value: number | null
|
||||
}
|
||||
|
||||
export interface PublicHealthObject {
|
||||
id: string
|
||||
kind: HealthObjectKind
|
||||
name: string
|
||||
status: ServiceHealthStatus
|
||||
request_count: number
|
||||
request_success: HealthRatio
|
||||
service_availability: HealthRatio
|
||||
coverage: {
|
||||
status: 'complete' | 'partial'
|
||||
sample_status: 'empty' | 'insufficient' | 'sufficient'
|
||||
classified_count: number
|
||||
unknown_failure_count: number
|
||||
excluded_count: number
|
||||
exclusion_policy: string
|
||||
}
|
||||
average_latency_ms: number | null
|
||||
latency_sample_count: number
|
||||
last_request_at: string | null
|
||||
timeline: Array<{
|
||||
from: string
|
||||
to: string
|
||||
status: ServiceHealthStatus
|
||||
service_availability: HealthRatio
|
||||
unknown_failure_count: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface AdminHealthObject extends PublicHealthObject {
|
||||
source_value: string
|
||||
attempts: {
|
||||
succeeded_count: number
|
||||
failed_count: number
|
||||
in_progress_count: number
|
||||
cancelled_count: number
|
||||
success: HealthRatio
|
||||
}
|
||||
}
|
||||
|
||||
export interface HealthMeta {
|
||||
schema_version: 2
|
||||
metric_version: string
|
||||
scope: { kind: 'published' | 'authenticated' | 'installation'; object_kind: HealthObjectKind }
|
||||
range: { from: string; to: string; timezone: 'UTC'; time_basis: string }
|
||||
generated_at: string
|
||||
data_through: string | null
|
||||
freshness: 'current' | 'stale' | 'unknown'
|
||||
policy: { version: string; minimum_samples: number; healthy_threshold: number; degraded_threshold: number }
|
||||
}
|
||||
|
||||
export interface HealthEnvelope<T> { meta: HealthMeta; data: T }
|
||||
export interface HealthSummaryV2 {
|
||||
status: ServiceHealthStatus
|
||||
object_count: number
|
||||
healthy_count: number
|
||||
degraded_count: number
|
||||
unavailable_count: number
|
||||
unknown_count: number
|
||||
requests: PublicHealthObject
|
||||
}
|
||||
export interface HealthObjectsPage<T> { items: T[]; total: number; limit: number; offset: number }
|
||||
export interface HealthQuery { kind: HealthObjectKind; window: HealthWindow; limit?: number; offset?: number }
|
||||
export type PublicHealthQuery = Omit<HealthQuery, 'kind'> & { kind: PublicHealthObjectKind }
|
||||
export interface HealthPublication {
|
||||
enabled: boolean
|
||||
objects: Array<{ public_id: string; kind: PublicHealthObjectKind; value: string; display_name: string }>
|
||||
}
|
||||
|
||||
const adminRoot = '/api/admin/endpoints/health/v2'
|
||||
const publicRoot = '/api/public/health/v2'
|
||||
const userRoot = '/api/users/me/health/v2'
|
||||
|
||||
export async function getAdminHealthSummary(query: HealthQuery, signal?: AbortSignal) {
|
||||
return (await client.get<HealthEnvelope<HealthSummaryV2>>(`${adminRoot}/summary`, { params: query, signal })).data
|
||||
}
|
||||
export async function getPublicHealthSummaryV2(query: PublicHealthQuery, signal?: AbortSignal) {
|
||||
return (await client.get<HealthEnvelope<HealthSummaryV2>>(`${publicRoot}/summary`, { params: query, signal })).data
|
||||
}
|
||||
export async function getAdminHealthObjects(query: HealthQuery, signal?: AbortSignal) {
|
||||
return (await client.get<HealthEnvelope<HealthObjectsPage<AdminHealthObject>>>(`${adminRoot}/objects`, { params: query, signal })).data
|
||||
}
|
||||
export async function getPublicHealthObjects(query: PublicHealthQuery, signal?: AbortSignal) {
|
||||
return (await client.get<HealthEnvelope<HealthObjectsPage<PublicHealthObject>>>(`${publicRoot}/objects`, { params: query, signal })).data
|
||||
}
|
||||
export async function getAdminHealthObject(id: string, query: HealthQuery, signal?: AbortSignal) {
|
||||
return (await client.get<HealthEnvelope<AdminHealthObject>>(`${adminRoot}/objects/${encodeURIComponent(id)}`, { params: query, signal })).data
|
||||
}
|
||||
export async function getPublicHealthObject(id: string, query: PublicHealthQuery, signal?: AbortSignal) {
|
||||
return (await client.get<HealthEnvelope<PublicHealthObject>>(`${publicRoot}/objects/${encodeURIComponent(id)}`, { params: query, signal })).data
|
||||
}
|
||||
export async function getUserHealthSummary(query: PublicHealthQuery, signal?: AbortSignal) {
|
||||
return (await client.get<HealthEnvelope<HealthSummaryV2>>(`${userRoot}/summary`, { params: query, signal })).data
|
||||
}
|
||||
export async function getUserHealthObjects(query: PublicHealthQuery, signal?: AbortSignal) {
|
||||
return (await client.get<HealthEnvelope<HealthObjectsPage<PublicHealthObject>>>(`${userRoot}/objects`, { params: query, signal })).data
|
||||
}
|
||||
export async function getUserHealthObject(id: string, query: PublicHealthQuery, signal?: AbortSignal) {
|
||||
return (await client.get<HealthEnvelope<PublicHealthObject>>(`${userRoot}/objects/${encodeURIComponent(id)}`, { params: query, signal })).data
|
||||
}
|
||||
export async function getHealthPublication() {
|
||||
return (await client.get<HealthPublication>(`${adminRoot}/publication`)).data
|
||||
}
|
||||
export async function saveHealthPublication(publication: HealthPublication) {
|
||||
return (await client.put<HealthPublication>(`${adminRoot}/publication`, publication)).data
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import client from '../client'
|
||||
export * from './health-v2'
|
||||
import type {
|
||||
HealthStatus,
|
||||
HealthSummary,
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import apiClient from './client'
|
||||
import type { GatewayMetricsSummary, AdminMonitoringResilienceStatus } from './monitoring'
|
||||
import type { ProviderPerformanceResponse } from './admin'
|
||||
|
||||
export interface OverviewRange {
|
||||
from: string
|
||||
to: string
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export interface OverviewQuery extends OverviewRange {
|
||||
user_id?: string
|
||||
api_key_id?: string
|
||||
request_type?: string
|
||||
credential_owner_id?: string
|
||||
attribution_kind?: string
|
||||
model?: string
|
||||
provider_id?: string
|
||||
api_format?: string
|
||||
endpoint_kind?: string
|
||||
is_stream?: boolean
|
||||
has_format_conversion?: boolean
|
||||
slow_threshold_ms?: number
|
||||
status?: string
|
||||
search?: string
|
||||
account_status?: string
|
||||
usage_status?: string
|
||||
sort?: string
|
||||
order?: 'asc' | 'desc'
|
||||
group_by?: string
|
||||
amount_basis?: string
|
||||
granularity?: 'hour' | 'day'
|
||||
limit?: number
|
||||
offset?: number
|
||||
payment_limit?: number
|
||||
payment_offset?: number
|
||||
}
|
||||
|
||||
export interface OverviewAmount {
|
||||
value: string | null
|
||||
currency: string
|
||||
basis: string
|
||||
status: 'known' | 'known_subtotal' | 'unknown' | 'estimated' | string
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
request_count: number
|
||||
successful_request_count: number
|
||||
failed_request_count: number
|
||||
cancelled_request_count: number
|
||||
in_flight_request_count: number
|
||||
unclassified_failure_count: number
|
||||
input_tokens: number | null
|
||||
output_tokens: number | null
|
||||
total_tokens: number | null
|
||||
usage_source?: 'reported' | 'estimated' | 'mixed' | 'unknown'
|
||||
usage_source_counts?: { reported: number; estimated: number; mixed: number; unknown: number }
|
||||
requests_per_second?: number
|
||||
requests_per_minute?: number
|
||||
tokens_per_minute?: number | null
|
||||
window_seconds?: number
|
||||
usage_active_users?: number
|
||||
enabled_users?: number
|
||||
success_rate: { value: number | null; numerator: number; denominator: number }
|
||||
latency_ms: { avg: number | null; p50: number | null; p95: number | null; p99: number | null; sample_count: number }
|
||||
rated_amount: OverviewAmount
|
||||
billable_amount: OverviewAmount
|
||||
quota_covered_amount: OverviewAmount
|
||||
wallet_consumed_amount: OverviewAmount
|
||||
wallet_debit_amount: OverviewAmount
|
||||
}
|
||||
|
||||
export interface OverviewMeta {
|
||||
schema_version: number
|
||||
metric_version: string
|
||||
scope: { kind: string; user_id?: string }
|
||||
range: OverviewRange & { time_basis: string }
|
||||
generated_at: string
|
||||
data_through: string | null
|
||||
read_revision: string
|
||||
available_metrics?: string[]
|
||||
projection?: {
|
||||
projection_from: string | null
|
||||
projection_through: string | null
|
||||
dirty_bucket_count: number
|
||||
missing_bucket_count: number
|
||||
read_enabled: boolean
|
||||
}
|
||||
coverage: {
|
||||
status: string
|
||||
request_count: number
|
||||
usage_available_count: number
|
||||
pricing_available_count: number
|
||||
settled_count: number
|
||||
attribution_available_count?: number
|
||||
unrecoverable_bucket_count?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface OverviewResponse<T> { meta: OverviewMeta; data: T }
|
||||
export type OverviewDashboardTotals = Pick<OverviewMetrics, 'request_count' | 'total_tokens' | 'billable_amount' | 'enabled_users'>
|
||||
export interface OverviewDashboard {
|
||||
today: OverviewResponse<OverviewMetrics>
|
||||
total: OverviewResponse<OverviewDashboardTotals>
|
||||
history_complete: boolean | null
|
||||
}
|
||||
export type OverviewDashboardTotal = { status: 'pending' } | {
|
||||
status: 'ready'
|
||||
total: OverviewResponse<OverviewDashboardTotals>
|
||||
history_complete: boolean | null
|
||||
stale: boolean
|
||||
}
|
||||
export interface OverviewDashboardSummary {
|
||||
stats_since: string
|
||||
generated_at: string
|
||||
timezone: string
|
||||
today_from: string
|
||||
window_seconds: number
|
||||
today: {
|
||||
request_count: number
|
||||
input_tokens: number | null
|
||||
output_tokens: number | null
|
||||
total_tokens: number | null
|
||||
billable_amount: OverviewAmount
|
||||
active_users: number
|
||||
cache_read_tokens: number | null
|
||||
cache_creation_tokens: number | null
|
||||
cache_input_tokens: number | null
|
||||
avg_first_byte_ms: number | null
|
||||
avg_response_ms: number | null
|
||||
stream_requests: number
|
||||
standard_requests: number
|
||||
}
|
||||
total: {
|
||||
request_count: number
|
||||
total_tokens: number | null
|
||||
billable_amount: OverviewAmount
|
||||
cache_read_tokens: number | null
|
||||
cache_input_tokens: number | null
|
||||
}
|
||||
users: { total: number; created_today: number; deleted_today: number }
|
||||
consecutive_active_days: number
|
||||
active_days: number
|
||||
activity_days: { date: string; requests: number }[]
|
||||
concurrency: {
|
||||
avg: number | null
|
||||
peak: number | null
|
||||
observed_from: string | null
|
||||
observed_through: string | null
|
||||
scope: 'node'
|
||||
coverage: 'partial' | 'complete' | 'unavailable'
|
||||
}
|
||||
}
|
||||
export interface OverviewPage<T> { items: T[]; total: number; limit: number; offset: number }
|
||||
export interface OverviewSeriesPoint extends OverviewMetrics { bucket_start: string }
|
||||
export interface OverviewBreakdown extends OverviewMetrics { id: string | null; label: string | null }
|
||||
export interface OverviewDashboardCharts {
|
||||
summary: OverviewMetrics
|
||||
series: OverviewSeriesPoint[]
|
||||
models: (OverviewBreakdown & { bucket_start: string })[]
|
||||
providers: OverviewBreakdown[]
|
||||
}
|
||||
export interface OverviewEmployee extends OverviewMetrics {
|
||||
user_id: string
|
||||
username: string
|
||||
email: string | null
|
||||
is_active: boolean
|
||||
last_used_at: string | null
|
||||
active_days: number
|
||||
finance?: OverviewUserFinance | null
|
||||
}
|
||||
export interface OverviewUserFinance {
|
||||
wallet_balance: OverviewAmount
|
||||
recharge_balance: OverviewAmount
|
||||
gift_balance: OverviewAmount
|
||||
recharge_amount: OverviewAmount
|
||||
recharge_count: number
|
||||
plan_purchase_amount: OverviewAmount
|
||||
plan_purchase_count: number
|
||||
gift_credit_amount: OverviewAmount
|
||||
gift_credit_count: number
|
||||
balance_time_basis: 'current'
|
||||
payment_time_basis: 'credited_at'
|
||||
}
|
||||
export interface OverviewUserPayment {
|
||||
id: string
|
||||
order_no: string
|
||||
kind: 'wallet_recharge' | 'plan_purchase' | 'gift_credit'
|
||||
amount: OverviewAmount
|
||||
payment_method: string
|
||||
credited_at: string
|
||||
}
|
||||
export interface OverviewUsers extends OverviewPage<OverviewEmployee> {
|
||||
summary?: OverviewMetrics & { user_count: number; active_user_count: number }
|
||||
finance_summary?: OverviewUserFinance | null
|
||||
}
|
||||
export interface OverviewEmployeeDetail {
|
||||
user: { id: string; username: string; email: string | null; is_active: boolean }
|
||||
summary: OverviewMetrics
|
||||
finance?: OverviewUserFinance | null
|
||||
payments?: OverviewPage<OverviewUserPayment> | null
|
||||
}
|
||||
export interface OverviewConsumption {
|
||||
id: string
|
||||
request_id: string
|
||||
started_at: string
|
||||
user_id: string | null
|
||||
model: string | null
|
||||
provider: string | null
|
||||
status: string
|
||||
settlement_status: string
|
||||
attribution_kind: string
|
||||
rated_amount: OverviewAmount
|
||||
billable_amount: OverviewAmount
|
||||
quota_covered_amount: OverviewAmount
|
||||
wallet_consumed_amount: OverviewAmount
|
||||
wallet_debit_amount: OverviewAmount
|
||||
}
|
||||
export interface OverviewCosts {
|
||||
summary: OverviewMetrics
|
||||
timeseries?: OverviewSeriesPoint[]
|
||||
supplier_estimated_cost: OverviewAmount
|
||||
supplier_verified_cost: OverviewAmount
|
||||
cache: { read_tokens: number | null; creation_tokens: number | null; read_cost: OverviewAmount; creation_cost: OverviewAmount; estimated_full_cost: OverviewAmount; estimated_savings: OverviewAmount; pricing_available_count?: number; request_count?: number }
|
||||
forecast: { amount: OverviewAmount; method: string; status: string; sample_days: number; period_end: string | null }
|
||||
}
|
||||
export interface OverviewExecutionActivity {
|
||||
observed_at: string
|
||||
observed_from: string
|
||||
window_seconds: number
|
||||
observed_window_seconds: number
|
||||
scope: { kind: 'node' }
|
||||
coverage: 'complete' | 'partial'
|
||||
providers: { provider_id: string; provider: string | null; requests_per_minute: number; current_concurrency: number }[]
|
||||
models: { model: string | null; requests_per_minute: number; current_concurrency: number }[]
|
||||
}
|
||||
export interface OverviewLive {
|
||||
observed_at: string | null
|
||||
window_seconds: number | null
|
||||
node_id: string | null
|
||||
scope: { kind: string; node_ids?: string[] }
|
||||
metrics?: GatewayMetricsSummary | null
|
||||
metrics_text?: string | null
|
||||
resilience: AdminMonitoringResilienceStatus | null
|
||||
unavailable_sections: string[]
|
||||
recent_activity?: OverviewResponse<OverviewMetrics>
|
||||
execution_activity?: OverviewExecutionActivity | null
|
||||
}
|
||||
export interface OverviewModelPerformance {
|
||||
model: string | null
|
||||
request_count: number
|
||||
success_count: number
|
||||
error_count: number
|
||||
success_rate: number | null
|
||||
avg_first_byte_time_ms: number | null
|
||||
avg_output_tps: number | null
|
||||
avg_response_time_ms: number | null
|
||||
}
|
||||
export interface OverviewPerformance {
|
||||
summary: OverviewMetrics
|
||||
timeseries: OverviewSeriesPoint[]
|
||||
providers: ProviderPerformanceResponse | null
|
||||
models?: OverviewModelPerformance[]
|
||||
errors: { reason: string; count: number }[]
|
||||
}
|
||||
|
||||
async function get<T>(path: string, params?: OverviewQuery, signal?: AbortSignal): Promise<OverviewResponse<T>> {
|
||||
return (await apiClient.get<OverviewResponse<T>>(`/api/admin/overview/${path}`, { params, signal })).data
|
||||
}
|
||||
|
||||
export const overviewApi = {
|
||||
async dashboardSummary(timezone: string, signal?: AbortSignal): Promise<OverviewDashboardSummary> {
|
||||
return (await apiClient.get<OverviewDashboardSummary>('/api/admin/overview/dashboard/summary', { params: { timezone }, signal })).data
|
||||
},
|
||||
async dashboard(timezone: string, signal?: AbortSignal): Promise<OverviewDashboard> {
|
||||
return (await apiClient.get<OverviewDashboard>('/api/admin/overview/dashboard', { params: { timezone }, signal })).data
|
||||
},
|
||||
async dashboardTotal(timezone: string, signal?: AbortSignal): Promise<OverviewDashboardTotal> {
|
||||
return (await apiClient.get<OverviewDashboardTotal>('/api/admin/overview/dashboard/total', { params: { timezone }, signal })).data
|
||||
},
|
||||
async dashboardCharts(range: OverviewRange, signal?: AbortSignal): Promise<OverviewResponse<OverviewDashboardCharts>> {
|
||||
const { from, to, timezone } = range
|
||||
return (await apiClient.get<OverviewResponse<OverviewDashboardCharts>>('/api/admin/overview/dashboard/charts', { params: { from, to, timezone, granularity: 'day' }, signal })).data
|
||||
},
|
||||
summary: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewMetrics>('summary', query, signal),
|
||||
timeseries: (query: OverviewQuery, signal?: AbortSignal) => get<{ items: OverviewSeriesPoint[]; granularity: string }>('timeseries', query, signal),
|
||||
breakdown: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewPage<OverviewBreakdown>>('breakdown', query, signal),
|
||||
users: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewUsers>('users', query, signal),
|
||||
user: (id: string, query: OverviewQuery, signal?: AbortSignal) => get<OverviewEmployeeDetail>(`users/${encodeURIComponent(id)}`, query, signal),
|
||||
consumption: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewPage<OverviewConsumption>>('consumption', query, signal),
|
||||
costs: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewCosts>('costs', query, signal),
|
||||
performance: (query: OverviewQuery, signal?: AbortSignal) => get<OverviewPerformance>('operations/performance', query, signal),
|
||||
live: (signal?: AbortSignal) => get<OverviewLive>('operations/live', undefined, signal),
|
||||
resources: (signal?: AbortSignal) => get<OverviewLive>('operations/resources', undefined, signal),
|
||||
async exportCsv(path: 'users' | 'consumption' | 'breakdown', query: OverviewQuery, signal?: AbortSignal): Promise<Blob> {
|
||||
const { limit: _limit, offset: _offset, ...filters } = query
|
||||
return (await apiClient.get<Blob>(`/api/admin/overview/${path}`, {
|
||||
params: { ...filters, format: 'csv' }, responseType: 'blob', signal,
|
||||
})).data
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import client from './client'
|
||||
import type { OverviewRange } from './overview'
|
||||
|
||||
export type ProviderExpenseKind = 'recharge' | 'subscription' | 'other'
|
||||
export interface ProviderExpense {
|
||||
id: string
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
kind: ProviderExpenseKind
|
||||
amount: string
|
||||
currency: string
|
||||
paid_at: string
|
||||
period_start: string | null
|
||||
period_end: string | null
|
||||
note: string | null
|
||||
external_reference: string | null
|
||||
status: 'recorded' | 'void'
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
voided_at: string | null
|
||||
}
|
||||
export interface ProviderExpenseInput {
|
||||
client_request_id: string
|
||||
provider_id: string
|
||||
kind: ProviderExpenseKind
|
||||
amount: string
|
||||
currency: string
|
||||
paid_at: string
|
||||
period_start?: string | null
|
||||
period_end?: string | null
|
||||
note?: string | null
|
||||
external_reference?: string | null
|
||||
}
|
||||
export interface ProviderExpenseTotals {
|
||||
currency: string
|
||||
amount: string
|
||||
entry_count: number
|
||||
recharge_amount: string
|
||||
subscription_amount: string
|
||||
other_amount: string
|
||||
}
|
||||
export interface ProviderExpenses {
|
||||
items: ProviderExpense[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
totals: ProviderExpenseTotals[]
|
||||
providers: { provider_id: string; provider_name: string; currency: string; amount: string; entry_count: number }[]
|
||||
}
|
||||
export interface ProviderAccountSubscription {
|
||||
group_name: string | null
|
||||
status: string | null
|
||||
daily_used_usd: number | null
|
||||
daily_limit_usd: number | null
|
||||
weekly_used_usd: number | null
|
||||
weekly_limit_usd: number | null
|
||||
monthly_used_usd: number | null
|
||||
monthly_limit_usd: number | null
|
||||
expires_at: string | null
|
||||
}
|
||||
export interface ProviderAccount {
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
is_active: boolean
|
||||
billing_type: string | null
|
||||
quota: { limit: number | string | null; used: number | string | null; remaining: number | string | null; currency: string; period_start: string | null; expires_at: string | null } | null
|
||||
balance: {
|
||||
status: string
|
||||
observed_at: string | null
|
||||
currency: string | null
|
||||
available: number | string | null
|
||||
used: number | string | null
|
||||
granted: number | string | null
|
||||
plan_name: string | null
|
||||
subscriptions: ProviderAccountSubscription[]
|
||||
} | null
|
||||
}
|
||||
export interface ProviderAccounts { observed_at: string; items: ProviderAccount[] }
|
||||
export type ProviderExpensesQuery = OverviewRange & { limit?: number; offset?: number }
|
||||
const base = '/api/admin/billing'
|
||||
export const providerFinanceApi = {
|
||||
async accounts(signal?: AbortSignal): Promise<ProviderAccounts> {
|
||||
return (await client.get<ProviderAccounts>(`${base}/provider-accounts`, { signal })).data
|
||||
},
|
||||
async expenses(params: ProviderExpensesQuery, signal?: AbortSignal): Promise<ProviderExpenses> {
|
||||
return (await client.get<ProviderExpenses>(`${base}/provider-expenses`, { params, signal })).data
|
||||
},
|
||||
async record(input: ProviderExpenseInput): Promise<{ item: ProviderExpense }> {
|
||||
return (await client.post<{ item: ProviderExpense }>(`${base}/provider-expenses`, input)).data
|
||||
},
|
||||
async void(id: string): Promise<{ item: ProviderExpense }> {
|
||||
return (await client.post<{ item: ProviderExpense }>(`${base}/provider-expenses/${encodeURIComponent(id)}/void`, {})).data
|
||||
},
|
||||
async exportExpenses(range: OverviewRange, signal?: AbortSignal): Promise<Blob> {
|
||||
return (await client.get<Blob>(`${base}/provider-expenses`, { params: { ...range, format: 'csv' }, responseType: 'blob', signal })).data
|
||||
},
|
||||
}
|
||||
@@ -133,6 +133,8 @@ export interface UsageByApiFormat {
|
||||
}
|
||||
|
||||
export interface UsageFilters {
|
||||
from?: string
|
||||
to?: string
|
||||
user_id?: string // UUID
|
||||
provider_id?: string // UUID
|
||||
model?: string
|
||||
@@ -499,6 +501,17 @@ export const usageApi = {
|
||||
},
|
||||
|
||||
async getAllUsageRecords(params?: {
|
||||
from?: string
|
||||
to?: string
|
||||
provider_id?: string
|
||||
api_key_id?: string
|
||||
request_id?: string
|
||||
attribution_kind?: string
|
||||
endpoint_kind?: string
|
||||
request_type?: string
|
||||
is_stream?: boolean
|
||||
has_format_conversion?: boolean
|
||||
slow_threshold_ms?: number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
@@ -539,6 +552,17 @@ export const usageApi = {
|
||||
},
|
||||
|
||||
async getAllUsageRecordTotal(params?: {
|
||||
from?: string
|
||||
to?: string
|
||||
provider_id?: string
|
||||
api_key_id?: string
|
||||
request_id?: string
|
||||
attribution_kind?: string
|
||||
endpoint_kind?: string
|
||||
request_type?: string
|
||||
is_stream?: boolean
|
||||
has_format_conversion?: boolean
|
||||
slow_threshold_ms?: number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
@@ -576,7 +600,7 @@ export const usageApi = {
|
||||
*/
|
||||
async getActiveRequests(
|
||||
ids?: string[],
|
||||
timeRange?: Pick<UsageFilters, 'start_date' | 'end_date' | 'preset' | 'timezone' | 'tz_offset_minutes'>
|
||||
timeRange?: Pick<UsageFilters, 'from' | 'to' | 'start_date' | 'end_date' | 'preset' | 'timezone' | 'tz_offset_minutes'>
|
||||
): Promise<{
|
||||
requests: Array<{
|
||||
id: string
|
||||
@@ -629,6 +653,10 @@ export const usageApi = {
|
||||
if (ids?.length) {
|
||||
params.ids = ids.join(',')
|
||||
}
|
||||
if (timeRange?.from && timeRange.to) {
|
||||
params.from = timeRange.from
|
||||
params.to = timeRange.to
|
||||
}
|
||||
if (timeRange?.start_date) {
|
||||
params.start_date = timeRange.start_date
|
||||
}
|
||||
|
||||
@@ -430,9 +430,14 @@ export const usersApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listUserPlanEntitlements(userId: string): Promise<AdminUserPlanEntitlementsResponse> {
|
||||
async listUserPlanEntitlements(
|
||||
userId: string,
|
||||
options: { include_inactive?: boolean } = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<AdminUserPlanEntitlementsResponse> {
|
||||
const response = await apiClient.get<AdminUserPlanEntitlementsResponse>(
|
||||
`/api/admin/users/${userId}/billing/entitlements`
|
||||
`/api/admin/users/${userId}/billing/entitlements`,
|
||||
{ params: options, signal },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<Popover
|
||||
:open="open"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="open ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
|
||||
:title="bellLabel"
|
||||
:aria-label="bellLabel"
|
||||
>
|
||||
<Bell class="h-4 w-4" />
|
||||
<span
|
||||
v-if="unreadCount !== null && unreadCount > 0"
|
||||
data-unread-badge
|
||||
aria-hidden="true"
|
||||
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-none text-primary-foreground ring-2 ring-background"
|
||||
>{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
:side-offset="8"
|
||||
:collision-padding="8"
|
||||
:aria-label="text('公告', 'Announcements')"
|
||||
class="w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden rounded-lg border-border bg-card p-0 text-card-foreground shadow-xl shadow-black/10"
|
||||
>
|
||||
<div class="flex min-w-0 items-center justify-between gap-2 border-b border-border bg-[color-mix(in_srgb,var(--muted)_45%,var(--card))] px-3 py-2">
|
||||
<h2 class="min-w-0 text-sm font-semibold">
|
||||
{{ text('公告', 'Announcements') }}
|
||||
</h2>
|
||||
<TooltipProvider :delay-duration="150">
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip
|
||||
v-for="action in actions"
|
||||
:key="action.name"
|
||||
>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:title="action.label"
|
||||
:aria-label="action.label"
|
||||
:disabled="action.disabled"
|
||||
@click="action.run"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
class="h-4 w-4"
|
||||
:class="{ 'animate-spin': action.spinning }"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ action.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="flex items-start justify-between gap-3 border-b border-border bg-[color-mix(in_srgb,var(--destructive)_6%,var(--card))] px-3 py-2 text-xs"
|
||||
>
|
||||
<span class="min-w-0 break-words text-destructive">{{ error }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 font-medium text-foreground underline underline-offset-2 disabled:opacity-40"
|
||||
:disabled="loading"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
{{ text('重试', 'Retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="max-h-[min(28rem,65dvh)] overflow-y-auto overscroll-contain"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<ul
|
||||
v-if="items.length"
|
||||
class="divide-y divide-border"
|
||||
>
|
||||
<li
|
||||
v-for="item in displayItems"
|
||||
:key="item.announcement.id"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full min-w-0 gap-2.5 px-3 py-3 text-left transition hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
:class="item.announcement.is_read ? 'text-muted-foreground' : 'bg-[color-mix(in_srgb,var(--primary)_5%,var(--card))] text-card-foreground'"
|
||||
:aria-label="`${item.announcement.title}, ${item.announcement.is_read ? text('已读', 'Read') : text('未读', 'Unread')}`"
|
||||
@click="emit('select', item.announcement)"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
:class="item.announcement.is_read ? 'bg-transparent' : 'bg-primary'"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 space-y-1">
|
||||
<span
|
||||
translate="no"
|
||||
class="block break-words text-sm leading-5 [overflow-wrap:anywhere]"
|
||||
:class="{ 'font-medium': !item.announcement.is_read }"
|
||||
>{{ item.announcement.title }}</span>
|
||||
<span
|
||||
translate="no"
|
||||
class="line-clamp-2 break-words text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]"
|
||||
>{{ item.summary }}</span>
|
||||
<time
|
||||
:datetime="item.announcement.created_at"
|
||||
class="block text-[11px] text-muted-foreground"
|
||||
>{{ formatTime(item.announcement.created_at) }}</time>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
v-else-if="!loading && !error"
|
||||
class="px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ text('暂无公告', 'No announcements') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="loading"
|
||||
role="status"
|
||||
class="flex items-center justify-center gap-2 px-4 py-6 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ text('加载中', 'Loading') }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasMore"
|
||||
type="button"
|
||||
class="flex min-h-10 w-full items-center justify-center gap-1 border-t border-border px-3 py-2 text-xs font-medium text-muted-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:opacity-40"
|
||||
:disabled="loading"
|
||||
@click="emit('loadMore')"
|
||||
>
|
||||
{{ text('加载更多', 'Load more') }}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Bell, CheckCheck, ChevronDown, Loader2, Plus, RefreshCw } from 'lucide-vue-next'
|
||||
import { marked } from 'marked'
|
||||
import type { Announcement } from '@/api/announcements'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
items: Announcement[]
|
||||
unreadCount: number | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
hasMore: boolean
|
||||
markingAll: boolean
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
select: [announcement: Announcement]
|
||||
refresh: []
|
||||
loadMore: []
|
||||
readAll: []
|
||||
create: []
|
||||
}>()
|
||||
|
||||
const text = (zh: string, en: string) => getI18nLocale() === 'zh-CN' ? zh : en
|
||||
const bellLabel = computed(() => props.unreadCount !== null && props.unreadCount > 0
|
||||
? text(`公告,${props.unreadCount} 条未读`, `Announcements, ${props.unreadCount} unread`)
|
||||
: text('公告', 'Announcements'))
|
||||
const actions = computed(() => [
|
||||
{ name: 'refresh', label: text('刷新公告', 'Refresh announcements'), icon: RefreshCw, disabled: props.loading, spinning: props.loading, run: () => emit('refresh') },
|
||||
{ name: 'readAll', label: text('全部标为已读', 'Mark all as read'), icon: props.markingAll ? Loader2 : CheckCheck, disabled: props.markingAll || props.unreadCount === null || props.unreadCount === 0, spinning: props.markingAll, run: () => emit('readAll') },
|
||||
...(props.canManage ? [
|
||||
{ name: 'create', label: text('发布公告', 'Publish announcement'), icon: Plus, disabled: false, spinning: false, run: () => emit('create') },
|
||||
] : []),
|
||||
])
|
||||
const displayItems = computed(() => props.items.map(announcement => {
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = sanitizeMarkdown(marked.parse(announcement.content, { async: false }))
|
||||
return { announcement, summary: (template.content.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 160) }
|
||||
}))
|
||||
|
||||
function formatTime(value: string) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString(getI18nLocale(), {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
:persistent="persistent"
|
||||
size="xl"
|
||||
no-padding
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="shrink-0 bg-card px-4 pt-4 sm:px-6 sm:pt-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Bell class="h-3.5 w-3.5 text-primary" />
|
||||
<span>{{ text('系统公告', 'Announcement') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!persistent"
|
||||
type="button"
|
||||
class="-mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="text('关闭', 'Close')"
|
||||
:title="text('关闭', 'Close')"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="bg-card px-4 pb-5 pt-3 sm:px-6">
|
||||
<header class="mb-5 border-b border-border pb-4">
|
||||
<h2
|
||||
class="text-lg font-semibold leading-7 tracking-normal text-foreground [overflow-wrap:anywhere]"
|
||||
translate="no"
|
||||
>
|
||||
{{ announcement?.title }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="announcement"
|
||||
class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 [overflow-wrap:anywhere]"
|
||||
translate="no"
|
||||
>{{ announcement.author.username }}</span>
|
||||
<time :datetime="announcement.created_at">{{ publishedAt }}</time>
|
||||
</div>
|
||||
</header>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<article
|
||||
v-if="announcement"
|
||||
class="announcement-body min-w-0 text-sm text-foreground"
|
||||
translate="no"
|
||||
v-html="content"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="w-full space-y-3">
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="text-sm text-destructive [overflow-wrap:anywhere]"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
v-if="!persistent"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-border bg-card font-medium"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
{{ text('关闭', 'Close') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="confirmRead"
|
||||
size="sm"
|
||||
class="gap-2 font-medium text-primary-foreground"
|
||||
:disabled="confirming"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
<Loader2
|
||||
v-if="confirming"
|
||||
class="h-4 w-4 animate-spin"
|
||||
/>
|
||||
<Check
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ confirming ? t('common.confirming') : t('common.confirmRead') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Bell, Check, Loader2, X } from 'lucide-vue-next'
|
||||
import { marked } from 'marked'
|
||||
import type { Announcement } from '@/api/announcements'
|
||||
import { Button, Dialog } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
announcement: Announcement | null
|
||||
persistent?: boolean
|
||||
confirmRead?: boolean
|
||||
confirming?: boolean
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [open: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
const text = (zh: string, en: string) => locale.value === 'zh-CN' ? zh : en
|
||||
const content = computed(() => sanitizeMarkdown(marked.parse(props.announcement?.content || '', { async: false })))
|
||||
const publishedAt = computed(() => {
|
||||
const date = new Date(props.announcement?.created_at || '')
|
||||
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString(locale.value, {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcement-body {
|
||||
font-family: var(--sans-serif);
|
||||
line-height: 1.8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.announcement-body :deep(*) {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.announcement-body :deep(p) {
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.announcement-body :deep(:is(p, ul, ol, blockquote, pre, table, hr)) {
|
||||
margin-block: 1em;
|
||||
}
|
||||
|
||||
.announcement-body :deep(:is(h1, h2, h3, h4, h5, h6)) {
|
||||
margin-block: 1.5em 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.announcement-body :deep(:is(h1, h2)) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.announcement-body :deep(:is(h3, h4, h5, h6)) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.announcement-body :deep(ul) {
|
||||
list-style: disc;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.announcement-body :deep(ol) {
|
||||
list-style: decimal;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.announcement-body :deep(li + li) {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.announcement-body :deep(a) {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.announcement-body :deep(blockquote) {
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 1em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.announcement-body :deep(:is(code, pre)) {
|
||||
border-radius: 4px;
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-family: var(--monospace);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.announcement-body :deep(code) {
|
||||
padding: 0.125em 0.3em;
|
||||
}
|
||||
|
||||
.announcement-body :deep(pre) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.announcement-body :deep(pre code) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.announcement-body :deep(table) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.announcement-body :deep(:is(th, td)) {
|
||||
min-width: 6rem;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5em 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.announcement-body :deep(th) {
|
||||
background: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.announcement-body :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.announcement-body :deep(> :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.announcement-body :deep(> :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -6,8 +6,11 @@
|
||||
<SelectTrigger
|
||||
class="h-8 w-40 text-xs border-border/60"
|
||||
:class="[presetTriggerClass]"
|
||||
:aria-label="legacyT('时间范围')"
|
||||
>
|
||||
<SelectValue :placeholder="legacyT('选择时间段')" />
|
||||
<SelectValue :placeholder="legacyT('选择时间段')">
|
||||
{{ presetOnly && selectedPreset === 'custom' ? legacyT('已选时段') : presetLabels[selectedPreset] }}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent :searchable="false">
|
||||
<SelectItem
|
||||
@@ -21,19 +24,21 @@
|
||||
</Select>
|
||||
|
||||
<div
|
||||
v-if="selectedPreset === 'custom'"
|
||||
v-if="selectedPreset === 'custom' && !presetOnly"
|
||||
class="flex max-w-full flex-wrap items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
v-model="startDate"
|
||||
type="date"
|
||||
class="h-8 w-36 text-xs border-border/60"
|
||||
:type="precise ? 'datetime-local' : 'date'"
|
||||
:class="precise ? 'w-48' : 'w-36'"
|
||||
class="h-8 max-w-full text-xs border-border/60"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">{{ legacyT('至') }}</span>
|
||||
<Input
|
||||
v-model="endDate"
|
||||
type="date"
|
||||
class="h-8 w-36 text-xs border-border/60"
|
||||
:type="precise ? 'datetime-local' : 'date'"
|
||||
:class="precise ? 'w-48' : 'w-36'"
|
||||
class="h-8 max-w-full text-xs border-border/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +82,7 @@ import {
|
||||
} from '@/components/ui'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { browserTimezone, zonedInput, zonedInstant } from '@/features/overview/query'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: DateRangeParams
|
||||
@@ -84,20 +90,24 @@ const props = withDefaults(defineProps<{
|
||||
allowHourly?: boolean
|
||||
presetOptions?: SelectablePreset[]
|
||||
presetTriggerClass?: string
|
||||
presetOnly?: boolean
|
||||
}>(), {
|
||||
presetOptions: () => ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'],
|
||||
presetTriggerClass: undefined,
|
||||
presetOnly: false,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DateRangeParams]
|
||||
}>()
|
||||
const { legacyT } = useI18n()
|
||||
const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const
|
||||
const selectablePresets = ['last1hour', 'today', 'yesterday', 'last24hours', 'last7days', 'last30days', 'last90days', 'custom'] as const
|
||||
type SelectablePreset = typeof selectablePresets[number]
|
||||
|
||||
const presetLabels = computed<Record<SelectablePreset, string>>(() => ({
|
||||
last1hour: legacyT('最近 1 小时'),
|
||||
today: legacyT('今天'),
|
||||
yesterday: legacyT('昨天'),
|
||||
last24hours: legacyT('最近 24 小时'),
|
||||
last7days: legacyT('最近7天'),
|
||||
last30days: legacyT('最近30天'),
|
||||
last90days: legacyT('最近90天'),
|
||||
@@ -106,8 +116,9 @@ const presetLabels = computed<Record<SelectablePreset, string>>(() => ({
|
||||
|
||||
const activePresetOptions = computed<SelectablePreset[]>(() => {
|
||||
const unique = new Set(props.presetOptions)
|
||||
const filtered = selectablePresets.filter((preset) => unique.has(preset))
|
||||
return filtered.length > 0 ? filtered : [...selectablePresets]
|
||||
const available = selectablePresets.filter(preset => !props.presetOnly || preset !== 'custom')
|
||||
const filtered = available.filter((preset) => unique.has(preset))
|
||||
return filtered.length > 0 ? filtered : available
|
||||
})
|
||||
|
||||
function defaultPreset(): SelectablePreset {
|
||||
@@ -120,15 +131,16 @@ function normalizePreset(value: DateRangeParams): SelectablePreset {
|
||||
if (value.preset && activePresetOptions.value.includes(value.preset as SelectablePreset)) {
|
||||
return value.preset as SelectablePreset
|
||||
}
|
||||
if (!value.preset && (value.start_date || value.end_date) && activePresetOptions.value.includes('custom')) {
|
||||
if (!value.preset && (value.from || value.start_date || value.end_date) && (props.presetOnly || activePresetOptions.value.includes('custom'))) {
|
||||
return 'custom'
|
||||
}
|
||||
return defaultPreset()
|
||||
}
|
||||
|
||||
const selectedPreset = ref<SelectablePreset>(normalizePreset(props.modelValue))
|
||||
const startDate = ref(props.modelValue.start_date || '')
|
||||
const endDate = ref(props.modelValue.end_date || '')
|
||||
const precise = computed(() => !!props.modelValue.from && !!props.modelValue.to)
|
||||
const startDate = ref(props.modelValue.from ? zonedInput(props.modelValue.from, props.modelValue.timezone || browserTimezone()) : props.modelValue.start_date || '')
|
||||
const endDate = ref(props.modelValue.to ? zonedInput(props.modelValue.to, props.modelValue.timezone || browserTimezone()) : props.modelValue.end_date || '')
|
||||
const selectedGranularity = ref(props.modelValue.granularity || 'day')
|
||||
|
||||
const showGranularity = computed(() => props.showGranularity !== false)
|
||||
@@ -150,6 +162,17 @@ function buildEmitValue(): DateRangeParams {
|
||||
const tz_offset_minutes = -new Date().getTimezoneOffset()
|
||||
|
||||
if (selectedPreset.value === 'custom') {
|
||||
if (precise.value) {
|
||||
const zone = props.modelValue.timezone || timezone
|
||||
if (startDate.value === zonedInput(props.modelValue.from!, zone)
|
||||
&& endDate.value === zonedInput(props.modelValue.to!, zone)) {
|
||||
return { ...props.modelValue, granularity: selectedGranularity.value }
|
||||
}
|
||||
const from = zonedInstant(startDate.value, zone)
|
||||
const to = zonedInstant(endDate.value, zone)
|
||||
if (!from || !to || from >= to) return props.modelValue
|
||||
return { from, to, timezone: zone, granularity: selectedGranularity.value }
|
||||
}
|
||||
const start = startDate.value <= endDate.value ? startDate.value : endDate.value
|
||||
const end = endDate.value >= startDate.value ? endDate.value : startDate.value
|
||||
return {
|
||||
@@ -170,6 +193,7 @@ function buildEmitValue(): DateRangeParams {
|
||||
}
|
||||
|
||||
function getValueKey(value: DateRangeParams): string {
|
||||
if (value.from && value.to) return `precise:${value.from}:${value.to}:${value.timezone}:${value.granularity}`
|
||||
// 只比较核心字段,忽略 timezone 和 tz_offset_minutes(这些每次都会重新计算)
|
||||
if (value.preset) {
|
||||
return `preset:${value.preset}:${value.granularity}`
|
||||
@@ -179,6 +203,13 @@ function getValueKey(value: DateRangeParams): string {
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
selectedPreset.value = normalizePreset(value)
|
||||
if (value.from && value.to) {
|
||||
startDate.value = zonedInput(value.from, value.timezone || browserTimezone())
|
||||
endDate.value = zonedInput(value.to, value.timezone || browserTimezone())
|
||||
} else {
|
||||
startDate.value = startDate.value.slice(0, 10)
|
||||
endDate.value = endDate.value.slice(0, 10)
|
||||
}
|
||||
if (value.start_date !== undefined) startDate.value = value.start_date || ''
|
||||
if (value.end_date !== undefined) endDate.value = value.end_date || ''
|
||||
if (value.granularity) selectedGranularity.value = value.granularity
|
||||
@@ -193,6 +224,8 @@ watch(activePresetOptions, () => {
|
||||
})
|
||||
|
||||
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
||||
// A fixed range can be displayed without exposing a custom date editor.
|
||||
if (props.presetOnly && selectedPreset.value === 'custom') return
|
||||
if (!allowHourly.value || !canUseHourly.value) {
|
||||
if (selectedGranularity.value === 'hour') {
|
||||
selectedGranularity.value = 'day'
|
||||
@@ -211,5 +244,5 @@ watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
||||
lastEmittedValue = newKey
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
}, { immediate: true })
|
||||
}, { immediate: !props.presetOnly })
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, reactive, type App, type ComputedRef } from 'vue'
|
||||
import AnnouncementBell from '../AnnouncementBell.vue'
|
||||
import type { Announcement } from '@/api/announcements'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
interface PopoverState { open: ComputedRef<boolean>; toggle: () => void }
|
||||
vi.mock('@/components/ui/popover', async () => {
|
||||
const { cloneVNode, computed, defineComponent, h, inject, provide } = await import('vue')
|
||||
return {
|
||||
Popover: defineComponent({
|
||||
props: { open: Boolean }, emits: ['update:open'],
|
||||
setup: (props, { slots, emit }) => {
|
||||
provide<PopoverState>('announcement-popover', { open: computed(() => props.open), toggle: () => emit('update:open', !props.open) })
|
||||
return () => h('div', slots.default?.())
|
||||
},
|
||||
}),
|
||||
PopoverTrigger: defineComponent({ setup: (_, { slots }) => {
|
||||
const state = inject<PopoverState>('announcement-popover')!
|
||||
return () => cloneVNode(slots.default!()[0]!, { onClick: state.toggle })
|
||||
} }),
|
||||
PopoverContent: defineComponent({ setup: (_, { slots }) => {
|
||||
const state = inject<PopoverState>('announcement-popover')!
|
||||
return () => state.open.value ? h('section', slots.default?.()) : null
|
||||
} }),
|
||||
}
|
||||
})
|
||||
vi.mock('@/components/ui/tooltip', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const passthrough = defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) })
|
||||
return { Tooltip: passthrough, TooltipProvider: passthrough, TooltipTrigger: passthrough, TooltipContent: defineComponent({ setup: () => () => null }) }
|
||||
})
|
||||
|
||||
const announcement = (is_read = false): Announcement => ({
|
||||
id: is_read ? 'read' : 'unread', title: is_read ? 'Earlier notice' : 'Service update',
|
||||
content: '**Scheduled maintenance** with [details](https://example.com).', type: 'info',
|
||||
priority: 0, is_pinned: false, is_active: true, requires_ack: false, is_read,
|
||||
author: { id: 'admin', username: 'Admin' }, created_at: '2026-09-15T02:00:00Z', updated_at: '2026-09-15T02:00:00Z',
|
||||
})
|
||||
type Props = InstanceType<typeof AnnouncementBell>['$props']
|
||||
const mounted: Array<{ app: App; root: HTMLElement }> = []
|
||||
function mount(overrides: Partial<Props> = {}) {
|
||||
const props = reactive({ open: true, items: [] as Announcement[], unreadCount: 0 as number | null, loading: false, error: null as string | null, hasMore: false, markingAll: false, canManage: false, ...overrides })
|
||||
const events = { select: vi.fn(), refresh: vi.fn(), loadMore: vi.fn(), readAll: vi.fn(), create: vi.fn(), open: vi.fn() }
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const app = createApp(() => h(AnnouncementBell, {
|
||||
...props,
|
||||
'onUpdate:open': value => { events.open(value); props.open = value },
|
||||
onSelect: events.select, onRefresh: events.refresh, onLoadMore: events.loadMore,
|
||||
onReadAll: events.readAll, onCreate: events.create,
|
||||
}))
|
||||
app.mount(root)
|
||||
mounted.push({ app, root })
|
||||
return { root, props, events }
|
||||
}
|
||||
function button(root: HTMLElement, label: string) {
|
||||
const result = Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(item => item.getAttribute('aria-label') === label || item.textContent?.trim() === label)
|
||||
if (!result) throw new Error(`Button not found: ${label}`)
|
||||
return result
|
||||
}
|
||||
beforeEach(() => setI18nLocale('zh-CN'))
|
||||
afterEach(() => { for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() } })
|
||||
|
||||
describe('AnnouncementBell presentation', () => {
|
||||
it('keeps unknown and zero counts hidden, caps the visible badge, and emits controlled open changes', async () => {
|
||||
const { root, props, events } = mount({ unreadCount: null, open: false })
|
||||
expect(root.querySelector('[data-unread-badge]')).toBeNull()
|
||||
expect(button(root, '公告').className).toContain('h-9 w-9')
|
||||
button(root, '公告').click()
|
||||
await nextTick()
|
||||
expect(events.open).toHaveBeenCalledWith(true)
|
||||
expect(button(root, '全部标为已读').disabled).toBe(true)
|
||||
props.unreadCount = 0
|
||||
await nextTick()
|
||||
expect(root.querySelector('[data-unread-badge]')).toBeNull()
|
||||
props.unreadCount = 137
|
||||
await nextTick()
|
||||
expect(root.querySelector('[data-unread-badge]')?.textContent).toBe('99+')
|
||||
expect(button(root, '公告,137 条未读').getAttribute('title')).toBe('公告,137 条未读')
|
||||
expect(button(root, '全部标为已读').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('emits selection without changing reading state and renders plain Markdown summaries', () => {
|
||||
const unread = announcement()
|
||||
const { root, events } = mount({ items: [unread, announcement(true)], unreadCount: 1 })
|
||||
expect(root.textContent).toContain('Scheduled maintenance with details.')
|
||||
expect(root.querySelector('a')).toBeNull()
|
||||
expect(button(root, 'Service update, 未读')).toBeTruthy()
|
||||
expect(button(root, 'Earlier notice, 已读')).toBeTruthy()
|
||||
button(root, 'Service update, 未读').click()
|
||||
expect(events.select).toHaveBeenCalledWith(unread)
|
||||
expect(unread.is_read).toBe(false)
|
||||
expect(events.readAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains rows during refresh failures and provides retry, loading, and pagination states', async () => {
|
||||
const { root, props, events } = mount({ items: [announcement()], error: '公告加载失败', hasMore: true })
|
||||
expect(root.querySelector('[role="alert"]')?.textContent).toContain('公告加载失败')
|
||||
button(root, '重试').click()
|
||||
button(root, '加载更多').click()
|
||||
expect(events.refresh).toHaveBeenCalledTimes(1)
|
||||
expect(events.loadMore).toHaveBeenCalledTimes(1)
|
||||
props.loading = true
|
||||
await nextTick()
|
||||
expect(root.querySelector('[role="status"]')?.textContent).toContain('加载中')
|
||||
expect(button(root, 'Service update, 未读')).toBeTruthy()
|
||||
expect(button(root, '重试').disabled).toBe(true)
|
||||
expect(button(root, '加载更多').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps publishing, refresh, and mark-all commands without a management navigation button', async () => {
|
||||
const { root, props, events } = mount({ unreadCount: 2 })
|
||||
expect(root.querySelector('[aria-label="发布公告"]')).toBeNull()
|
||||
expect(root.querySelector('[aria-label="管理公告"]')).toBeNull()
|
||||
props.canManage = true
|
||||
await nextTick()
|
||||
expect(root.querySelector('[aria-label="管理公告"]')).toBeNull()
|
||||
button(root, '发布公告').click()
|
||||
button(root, '刷新公告').click()
|
||||
button(root, '全部标为已读').click()
|
||||
expect(events.create).toHaveBeenCalledTimes(1)
|
||||
expect(events.refresh).toHaveBeenCalledTimes(1)
|
||||
expect(events.readAll).toHaveBeenCalledTimes(1)
|
||||
props.markingAll = true
|
||||
await nextTick()
|
||||
expect(button(root, '全部标为已读').disabled).toBe(true)
|
||||
button(root, '全部标为已读').click()
|
||||
expect(events.readAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows an empty state without inventing an unread count and follows the active locale', async () => {
|
||||
const { root } = mount({ unreadCount: null })
|
||||
expect(root.textContent).toContain('暂无公告')
|
||||
setI18nLocale('en-US')
|
||||
await nextTick()
|
||||
expect(button(root, 'Announcements')).toBeTruthy()
|
||||
expect(root.textContent).toContain('No announcements')
|
||||
expect(root.querySelector('[data-unread-badge]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import TimeRangePicker from '../TimeRangePicker.vue'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const passthrough = defineComponent({ setup: (_, { slots }) => () => slots.default?.() })
|
||||
return {
|
||||
Select: defineComponent({
|
||||
props: { modelValue: String }, emits: ['update:modelValue'],
|
||||
setup: (props, { emit, slots }) => () => h('select', {
|
||||
value: props.modelValue,
|
||||
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
|
||||
}, slots.default?.()),
|
||||
}),
|
||||
SelectItem: defineComponent({ props: { value: String }, setup: (props, { slots }) => () => h('option', { value: props.value }, slots.default?.()) }),
|
||||
SelectContent: passthrough, SelectTrigger: defineComponent({ render: () => null }), SelectValue: passthrough,
|
||||
Input: defineComponent({
|
||||
props: { modelValue: String, type: String }, emits: ['update:modelValue'],
|
||||
setup: (props, { emit }) => () => h('input', {
|
||||
type: props.type, value: props.modelValue,
|
||||
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
let app: App | undefined
|
||||
afterEach(() => { app?.unmount(); app = undefined })
|
||||
|
||||
describe('time range mode changes', () => {
|
||||
it('keeps a fixed range read-only in preset-only mode without emitting a replacement on mount', async () => {
|
||||
const range = ref<DateRangeParams>({ from: '2026-09-18T00:00:00Z', to: '2026-09-18T01:00:00Z', timezone: 'UTC' })
|
||||
const update = vi.fn((value: DateRangeParams) => { range.value = value })
|
||||
const root = document.createElement('div')
|
||||
app = createApp(defineComponent({ setup: () => () => h(TimeRangePicker, {
|
||||
modelValue: range.value, showGranularity: false, presetOnly: true,
|
||||
presetOptions: ['last1hour', 'today', 'last24hours', 'last7days'],
|
||||
'onUpdate:modelValue': update,
|
||||
}) }))
|
||||
app.mount(root)
|
||||
await nextTick()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(root.querySelector('input')).toBeNull()
|
||||
expect(Array.from(root.querySelectorAll('option'), option => option.value)).toEqual(['last1hour', 'today', 'last24hours', 'last7days'])
|
||||
expect(range.value).toMatchObject({ from: '2026-09-18T00:00:00Z', to: '2026-09-18T01:00:00Z', timezone: 'UTC' })
|
||||
const select = root.querySelector('select')!
|
||||
select.value = 'last1hour'
|
||||
select.dispatchEvent(new Event('change'))
|
||||
await nextTick()
|
||||
expect(update).toHaveBeenCalledTimes(1)
|
||||
expect(range.value.preset).toBe('last1hour')
|
||||
expect(root.querySelector('input')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not repeat a preset selection when the parent synchronizes its range', async () => {
|
||||
const range = ref<DateRangeParams>({ preset: 'today', timezone: 'UTC', granularity: 'day' })
|
||||
const update = vi.fn()
|
||||
const root = document.createElement('div')
|
||||
app = createApp(defineComponent({ setup: () => () => h(TimeRangePicker, {
|
||||
modelValue: range.value, showGranularity: false, presetOnly: true,
|
||||
presetOptions: ['last1hour', 'today', 'last24hours'],
|
||||
'onUpdate:modelValue': update,
|
||||
}) }))
|
||||
app.mount(root)
|
||||
await nextTick()
|
||||
range.value = { ...range.value }
|
||||
await nextTick()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
range.value = { ...range.value, preset: 'last24hours' }
|
||||
await nextTick()
|
||||
expect(root.querySelector('select')?.value).toBe('last24hours')
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps valid calendar dates after precise range → preset → custom', async () => {
|
||||
const range = ref<DateRangeParams>({ from: '2026-09-18T00:00:00Z', to: '2026-09-18T01:00:00Z', timezone: 'UTC' })
|
||||
const root = document.createElement('div')
|
||||
app = createApp(defineComponent({ setup: () => () => h(TimeRangePicker, {
|
||||
modelValue: range.value, showGranularity: false,
|
||||
'onUpdate:modelValue': value => { range.value = value },
|
||||
}) }))
|
||||
app.mount(root)
|
||||
await nextTick()
|
||||
expect(root.querySelector<HTMLInputElement>('input')?.type).toBe('datetime-local')
|
||||
const select = root.querySelector('select')!
|
||||
select.value = 'today'
|
||||
select.dispatchEvent(new Event('change'))
|
||||
await nextTick()
|
||||
select.value = 'custom'
|
||||
select.dispatchEvent(new Event('change'))
|
||||
await nextTick()
|
||||
expect(range.value).toMatchObject({ start_date: '2026-09-18', end_date: '2026-09-18' })
|
||||
expect(range.value.from).toBeUndefined()
|
||||
expect(Array.from(root.querySelectorAll('input')).map(input => [input.type, input.value])).toEqual([
|
||||
['date', '2026-09-18'], ['date', '2026-09-18'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
import type { Announcement } from '@/api/announcements'
|
||||
|
||||
export const openAnnouncementKey: InjectionKey<(announcement: Announcement) => void> = Symbol('open-announcement')
|
||||
@@ -12,9 +12,15 @@
|
||||
{{ formatDay(tooltip.day.date) }}
|
||||
</p>
|
||||
<p class="mt-0.5">
|
||||
{{ t('heatmap.requests', { count: tooltip.day.requests }) }} · {{ formatTokens(tooltip.day.total_tokens) }}
|
||||
{{ t('heatmap.requests', { count: tooltip.day.requests }) }}
|
||||
<template v-if="tooltip.day.total_tokens != null">
|
||||
· {{ formatTokens(tooltip.day.total_tokens) }}
|
||||
</template>
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
<p
|
||||
v-if="tooltip.day.total_cost != null"
|
||||
class="text-[11px] text-muted-foreground"
|
||||
>
|
||||
{{ t('heatmap.cost', { value: formatCurrency(tooltip.day.total_cost) }) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -52,14 +58,20 @@
|
||||
|
||||
<div
|
||||
v-if="weekColumns.length > 0"
|
||||
ref="heatmapScroller"
|
||||
class="flex w-full gap-3 overflow-x-auto"
|
||||
:class="compact ? 'overflow-y-hidden pb-1' : ''"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col text-[10px] text-muted-foreground flex-shrink-0"
|
||||
:style="verticalGapStyle"
|
||||
:class="compact ? 'sticky left-0 z-10 bg-card pr-2' : ''"
|
||||
:style="[verticalGapStyle, compact ? { paddingTop: '24px' } : undefined]"
|
||||
>
|
||||
<!-- Placeholder to align with month markers -->
|
||||
<div class="text-[10px] mb-3 invisible">
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="text-[10px] mb-3 invisible"
|
||||
>
|
||||
M
|
||||
</div>
|
||||
<span
|
||||
@@ -76,6 +88,19 @@
|
||||
class="relative block w-full"
|
||||
>
|
||||
<div
|
||||
v-if="compact"
|
||||
class="relative mb-2 h-4 text-[10px] leading-4 text-muted-foreground/80"
|
||||
:style="{ width: `${gridWidth}px` }"
|
||||
>
|
||||
<span
|
||||
v-for="marker in compactMonthMarkers"
|
||||
:key="marker.weekIndex"
|
||||
class="absolute top-0 whitespace-nowrap"
|
||||
:style="{ left: `${marker.left}px` }"
|
||||
>{{ marker.label }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex text-[10px] text-muted-foreground/80 mb-3"
|
||||
:style="horizontalGapStyle"
|
||||
>
|
||||
@@ -84,6 +109,7 @@
|
||||
:key="`month-${weekIndex}`"
|
||||
:style="monthCellStyle"
|
||||
class="whitespace-nowrap text-left"
|
||||
:class="compact ? 'shrink-0' : ''"
|
||||
>
|
||||
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
|
||||
</div>
|
||||
@@ -96,6 +122,7 @@
|
||||
v-for="(week, weekIndex) in weekColumns"
|
||||
:key="weekIndex"
|
||||
class="flex flex-col"
|
||||
:class="compact ? 'shrink-0' : ''"
|
||||
:style="verticalGapStyle"
|
||||
>
|
||||
<div
|
||||
@@ -105,16 +132,19 @@
|
||||
>
|
||||
<div
|
||||
v-if="day"
|
||||
class="rounded-[4px] transition-all duration-200 hover:shadow-lg cursor-pointer cell-emerge"
|
||||
:style="[cellSquareStyle, getCellStyle(day.requests), getCellAnimationDelay(weekIndex, dayIndex)]"
|
||||
class="cursor-pointer cell-emerge rounded-[4px]"
|
||||
:class="compact
|
||||
? 'transition-[filter] duration-150 hover:brightness-90'
|
||||
: 'transition-all duration-200 hover:shadow-lg'"
|
||||
:style="[cellStyle, getCellStyle(day.requests), getCellAnimationDelay(weekIndex, dayIndex)]"
|
||||
:title="buildTooltip(day)"
|
||||
@mouseenter="handleHover(day, $event)"
|
||||
@mouseleave="clearHover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:style="cellSquareStyle"
|
||||
class="rounded-[4px] bg-transparent"
|
||||
:style="cellStyle"
|
||||
class="bg-transparent rounded-[4px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,11 +172,13 @@ const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
showHeader?: boolean
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
data: undefined,
|
||||
title: undefined,
|
||||
subtitle: undefined,
|
||||
showHeader: true
|
||||
showHeader: true,
|
||||
compact: false
|
||||
})
|
||||
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
@@ -162,10 +194,11 @@ function formatDay(value: string): string {
|
||||
}
|
||||
|
||||
type DayWithMeta = ActivityHeatmapDay & { dateObj: Date }
|
||||
const heatmapScroller = ref<HTMLElement | null>(null)
|
||||
const heatmapWrapper = ref<HTMLElement | null>(null)
|
||||
const heatmapWidth = ref(0)
|
||||
const cellSize = ref(10)
|
||||
const cellGap = ref(4)
|
||||
const cellGap = ref(props.compact ? 2 : 4)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
const tooltip = ref<{ day: ActivityHeatmapDay | null; x: number; y: number; visible: boolean; below: boolean }>({
|
||||
day: null,
|
||||
@@ -180,7 +213,7 @@ const tooltipStyle = computed(() => ({
|
||||
transform: tooltip.value.below ? 'translate(-50%, 0)' : 'translate(-50%, -100%)',
|
||||
}))
|
||||
|
||||
const cellSquareStyle = computed(() => ({
|
||||
const cellStyle = computed(() => ({
|
||||
width: `${cellSize.value}px`,
|
||||
height: `${cellSize.value}px`,
|
||||
}))
|
||||
@@ -270,6 +303,46 @@ const monthMarkers = computed(() => {
|
||||
return markers
|
||||
})
|
||||
|
||||
const gridWidth = computed(() => {
|
||||
const columns = weekColumns.value.length
|
||||
return columns * cellSize.value + Math.max(0, columns - 1) * cellGap.value
|
||||
})
|
||||
|
||||
const compactMonthMarkers = computed(() => {
|
||||
const markers = Object.entries(monthMarkers.value).map(([index, label]) => {
|
||||
const weekIndex = Number(index)
|
||||
// At 10px, digits and Latin letters need less room than full-width month labels.
|
||||
const width = Array.from(label).reduce((total, character) => {
|
||||
return total + (character.charCodeAt(0) <= 0xff ? 6 : 10)
|
||||
}, 0)
|
||||
return {
|
||||
weekIndex,
|
||||
label,
|
||||
width,
|
||||
left: Math.max(0, Math.min(weekIndex * (cellSize.value + cellGap.value), gridWidth.value - width)),
|
||||
}
|
||||
})
|
||||
|
||||
// Keep the most recent month visible when neighboring labels would overlap.
|
||||
let nextLeft = Infinity
|
||||
return markers.reverse().filter(marker => {
|
||||
if (marker.left + marker.width + 8 > nextLeft) return false
|
||||
nextLeft = marker.left
|
||||
return true
|
||||
}).reverse()
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => props.compact, heatmapScroller, heatmapWidth, gridWidth],
|
||||
async () => {
|
||||
if (!props.compact) return
|
||||
await nextTick()
|
||||
const scroller = heatmapScroller.value
|
||||
if (scroller) scroller.scrollLeft = scroller.scrollWidth
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
|
||||
@@ -321,7 +394,7 @@ onMounted(() => {
|
||||
}
|
||||
mediaQuery = window.matchMedia('(min-width: 640px)')
|
||||
const updateGap = () => {
|
||||
cellGap.value = mediaQuery && mediaQuery.matches ? 4 : 2
|
||||
cellGap.value = !props.compact && mediaQuery && mediaQuery.matches ? 4 : 2
|
||||
recalcCellSize()
|
||||
}
|
||||
mediaQueryHandler = () => updateGap()
|
||||
@@ -399,8 +472,9 @@ function getCellStyle(requests: number) {
|
||||
|
||||
function buildTooltip(day: ActivityHeatmapDay): string {
|
||||
const dateLabel = formatDay(day.date)
|
||||
const costLabel = formatCurrency(day.total_cost || 0)
|
||||
const parts = [dateLabel, t('heatmap.requests', { count: day.requests }), `${formatTokens(day.total_tokens)} tokens`, costLabel]
|
||||
const parts = [dateLabel, t('heatmap.requests', { count: day.requests })]
|
||||
if (day.total_tokens != null) parts.push(`${formatTokens(day.total_tokens)} tokens`)
|
||||
if (day.total_cost != null) parts.push(formatCurrency(day.total_cost))
|
||||
if (day.actual_total_cost !== undefined) {
|
||||
parts.push(t('heatmap.actualCost', { value: formatCurrency(day.actual_total_cost) }))
|
||||
}
|
||||
|
||||
@@ -22,21 +22,28 @@
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div class="relative flex h-full items-end justify-center overflow-hidden text-center sm:items-center sm:p-0 pointer-events-none">
|
||||
<div
|
||||
class="relative flex h-full overflow-hidden pointer-events-none"
|
||||
:class="isDrawer ? 'items-stretch justify-end' : 'items-end justify-center text-center sm:items-center sm:p-0'"
|
||||
>
|
||||
<!-- 对话框内容 -->
|
||||
<Transition
|
||||
:appear="isDrawer"
|
||||
enter-active-class="duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
|
||||
:enter-from-class="isDrawer ? 'translate-x-full' : 'opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'"
|
||||
:enter-to-class="isDrawer ? 'translate-x-0' : 'opacity-100 translate-y-0 sm:scale-100'"
|
||||
leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
:leave-from-class="isDrawer ? 'translate-x-0' : 'opacity-100 translate-y-0 sm:scale-100'"
|
||||
:leave-to-class="isDrawer ? 'translate-x-full' : 'opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="relative flex max-h-[100dvh] w-full transform flex-col overflow-hidden rounded-t-xl border border-x-0 border-b-0 border-border bg-background text-left shadow-2xl transition-all pointer-events-auto sm:my-8 sm:w-full sm:max-h-[calc(100dvh-4rem)] sm:rounded-lg sm:border"
|
||||
class="relative flex max-h-[100dvh] w-full transform flex-col overflow-hidden border-border bg-background text-left shadow-2xl transition-all pointer-events-auto"
|
||||
:style="{ zIndex: contentZIndex }"
|
||||
:class="maxWidthClass"
|
||||
:class="[maxWidthClass, isDrawer ? 'h-full border-l' : 'rounded-t-xl border border-x-0 border-b-0 sm:my-8 sm:w-full sm:max-h-[calc(100dvh-4rem)] sm:rounded-lg sm:border']"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header 区域:优先使用 slot,否则使用 title prop -->
|
||||
@@ -100,6 +107,7 @@ import { DIALOG_CONTEXT_KEY } from './context'
|
||||
const props = defineProps<{
|
||||
open?: boolean
|
||||
modelValue?: boolean
|
||||
placement?: 'center' | 'right'
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'
|
||||
title?: string
|
||||
@@ -122,6 +130,7 @@ provide(DIALOG_CONTEXT_KEY, true)
|
||||
|
||||
// 获取 slots 以便在模板中使用
|
||||
const slots = useSlots()
|
||||
const isDrawer = computed(() => props.placement === 'right')
|
||||
|
||||
// 统一处理 open 状态
|
||||
const isOpen = computed(() => {
|
||||
@@ -170,6 +179,7 @@ const maxWidthClass = computed(() => {
|
||||
|
||||
const contentBodyClass = computed(() => [
|
||||
'min-h-0 min-w-0 overflow-y-auto overscroll-contain',
|
||||
isDrawer.value ? 'flex-1' : '',
|
||||
props.noPadding ? '' : 'px-4 py-3 sm:px-6',
|
||||
].filter(Boolean).join(' '))
|
||||
|
||||
|
||||
@@ -8,12 +8,16 @@ const props = withDefaults(defineProps<{
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
collisionPadding?: number
|
||||
ariaLabel?: string
|
||||
}>(), {
|
||||
class: undefined,
|
||||
align: 'center',
|
||||
side: 'bottom',
|
||||
sideOffset: 4,
|
||||
alignOffset: 0,
|
||||
collisionPadding: 0,
|
||||
ariaLabel: undefined,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -33,6 +37,8 @@ const props = withDefaults(defineProps<{
|
||||
:side="props.side"
|
||||
:side-offset="props.sideOffset"
|
||||
:align-offset="props.alignOffset"
|
||||
:collision-padding="props.collisionPadding"
|
||||
:aria-label="props.ariaLabel"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading"
|
||||
:class="active ? 'text-primary' : ''"
|
||||
:disabled="loading && !active"
|
||||
:aria-pressed="active"
|
||||
:title="title"
|
||||
@click="handleClick"
|
||||
>
|
||||
<RefreshCcw
|
||||
class="w-3.5 h-3.5"
|
||||
:class="loading ? 'animate-spin' : ''"
|
||||
:class="loading || active ? 'animate-spin' : ''"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -20,6 +22,7 @@ import { RefreshCcw } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
loading?: boolean
|
||||
active?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
@@ -29,6 +32,7 @@ interface Emits {
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
active: undefined,
|
||||
title: '刷新'
|
||||
})
|
||||
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
<slot name="header">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<!-- 左侧:标题 -->
|
||||
<h3 class="text-sm sm:text-base font-semibold shrink-0">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm sm:text-base font-semibold shrink-0">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:操作区 -->
|
||||
<div
|
||||
@@ -36,6 +44,7 @@ import { Card } from '@/components/ui'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
<template>
|
||||
<div class="relative min-w-0 max-w-full space-y-5 pb-8">
|
||||
<header class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">
|
||||
{{ publicPage ? '服务状态' : '健康监控' }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="meta"
|
||||
class="mt-2 text-xs text-muted-foreground"
|
||||
>
|
||||
更新于 {{ dateTime(meta.generated_at) }}
|
||||
<span
|
||||
v-if="meta.freshness === 'stale'"
|
||||
class="ml-2 text-amber-600"
|
||||
>数据已过期</span>
|
||||
<span
|
||||
v-else-if="meta.freshness === 'unknown'"
|
||||
class="ml-2"
|
||||
>采集进度未知</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
class="sr-only"
|
||||
for="health-window"
|
||||
>时间范围</label>
|
||||
<select
|
||||
id="health-window"
|
||||
:value="window"
|
||||
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
@change="changeWindow"
|
||||
>
|
||||
<option value="1h">
|
||||
近 1 小时
|
||||
</option><option value="6h">
|
||||
近 6 小时
|
||||
</option><option value="24h">
|
||||
近 24 小时
|
||||
</option><option value="72h">
|
||||
近 72 小时
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
class="flex h-9 items-center gap-2 rounded-md border border-input px-3 text-sm hover:bg-muted"
|
||||
@click="openPublication"
|
||||
>
|
||||
<Globe class="h-4 w-4" />状态页发布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="刷新"
|
||||
aria-label="刷新健康数据"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md border border-input hover:bg-muted disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-4 w-4"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<nav
|
||||
class="flex gap-5 overflow-x-auto border-b border-border"
|
||||
aria-label="健康对象"
|
||||
>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.kind"
|
||||
type="button"
|
||||
class="shrink-0 border-b-2 px-1 pb-3 text-sm"
|
||||
:class="kind === tab.kind ? 'border-primary font-medium text-foreground' : 'border-transparent text-muted-foreground'"
|
||||
:aria-current="kind === tab.kind ? 'page' : undefined"
|
||||
@click="setQuery({ kind: tab.kind, offset: undefined, object: undefined }, true)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<div
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="flex items-start gap-2 border-l-2 border-destructive bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />{{ error }}
|
||||
</div>
|
||||
<section
|
||||
v-if="summary"
|
||||
class="grid grid-cols-2 gap-y-5 border-b border-border pb-5 md:grid-cols-4"
|
||||
aria-label="健康摘要"
|
||||
>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
总体状态
|
||||
</div><div class="mt-2 flex items-center gap-2 text-lg font-semibold">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full"
|
||||
:class="statusColor(summary.status)"
|
||||
/>{{ statusLabel(summary.status) }}
|
||||
</div><div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summary.object_count }} 个对象
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
服务可用率
|
||||
</div><div class="mt-2 text-lg font-semibold tabular-nums">
|
||||
{{ percentage(summary.requests.service_availability.value) }}
|
||||
</div><div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summary.requests.service_availability.denominator.toLocaleString() }} 个有效样本
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
异常对象
|
||||
</div><div class="mt-2 text-lg font-semibold tabular-nums">
|
||||
{{ summary.degraded_count + summary.unavailable_count }}
|
||||
</div><div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summary.unknown_count }} 个状态未知
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
最终请求成功率
|
||||
</div><div class="mt-2 text-lg font-semibold tabular-nums">
|
||||
{{ percentage(summary.requests.request_success.value) }}
|
||||
</div><div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ summary.requests.request_count.toLocaleString() }} 次请求
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-if="loading && !page"
|
||||
role="status"
|
||||
class="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<LoaderCircle class="h-4 w-4 animate-spin" />加载中
|
||||
</div>
|
||||
<template v-else-if="page">
|
||||
<div
|
||||
v-if="page.items.length === 0"
|
||||
class="border-y border-dashed border-border py-16 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ publicPage ? '此视角暂无公开服务' : '此视角暂无监控对象' }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="relative max-w-full overflow-x-auto"
|
||||
>
|
||||
<table class="health-object-table w-full min-w-[740px] text-left text-sm">
|
||||
<thead class="border-b border-border text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 pr-4 font-medium">
|
||||
{{ activeLabel }}
|
||||
</th><th class="px-3 py-3 font-medium">
|
||||
状态
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
服务可用率
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
请求数
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
平均耗时
|
||||
</th><th class="px-3 py-3 font-medium">
|
||||
窗口状态
|
||||
</th><th class="w-10">
|
||||
<span class="sr-only">详情</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr
|
||||
v-for="object in page.items"
|
||||
:key="object.id"
|
||||
class="hover:bg-muted/40"
|
||||
>
|
||||
<td class="max-w-64 py-4 pr-4">
|
||||
<button
|
||||
class="break-words text-left font-medium hover:text-primary"
|
||||
type="button"
|
||||
@click="selectObject(object.id)"
|
||||
>
|
||||
{{ object.name }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<span class="inline-flex items-center gap-2"><span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="statusColor(object.status)"
|
||||
/>{{ statusLabel(object.status) }}</span><div
|
||||
v-if="object.coverage.sample_status !== 'sufficient'"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ object.coverage.sample_status === 'empty' ? '暂无样本' : '样本不足' }}
|
||||
</div><div
|
||||
v-else-if="object.coverage.status === 'partial'"
|
||||
class="mt-1 text-xs text-amber-600"
|
||||
>
|
||||
分类不完整
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-right tabular-nums">
|
||||
{{ percentage(object.service_availability.value) }}
|
||||
</td><td class="px-3 py-4 text-right tabular-nums">
|
||||
{{ object.request_count.toLocaleString() }}
|
||||
</td><td class="whitespace-nowrap px-3 py-4 text-right tabular-nums">
|
||||
{{ object.average_latency_ms == null ? '未知' : `${Math.round(object.average_latency_ms).toLocaleString()} ms` }}
|
||||
</td>
|
||||
<td class="px-3 py-4">
|
||||
<div class="flex h-7 min-w-36 gap-0.5">
|
||||
<span
|
||||
v-for="(bucket, index) in object.timeline"
|
||||
:key="index"
|
||||
class="min-w-1 flex-1 rounded-sm"
|
||||
:class="statusColor(bucket.status)"
|
||||
:title="`${dateTime(bucket.from)} - ${dateTime(bucket.to)}: ${statusLabel(bucket.status)} (${bucket.service_availability.denominator} 个样本)`"
|
||||
/><span
|
||||
v-if="object.timeline.length === 0"
|
||||
class="flex-1 rounded-sm bg-muted"
|
||||
title="暂无样本"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="pl-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted"
|
||||
:aria-label="`查看 ${object.name}`"
|
||||
title="查看详情"
|
||||
@click="selectObject(object.id)"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-t border-border pt-4 text-xs text-muted-foreground">
|
||||
<span>共 {{ page.total }} 个对象</span><div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
title="上一页"
|
||||
aria-label="上一页"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md border border-input disabled:opacity-30"
|
||||
:disabled="offset === 0 || loading"
|
||||
@click="setQuery({ offset: String(Math.max(0, offset - 25)) })"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button><span>{{ Math.floor(offset / 25) + 1 }} / {{ Math.max(1, Math.ceil(page.total / 25)) }}</span><button
|
||||
type="button"
|
||||
title="下一页"
|
||||
aria-label="下一页"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md border border-input disabled:opacity-30"
|
||||
:disabled="offset + 25 >= page.total || loading"
|
||||
@click="setQuery({ offset: String(offset + 25) })"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Dialog
|
||||
:open="Boolean(selectedId)"
|
||||
:title="detail?.name || '健康详情'"
|
||||
max-width="2xl"
|
||||
@update:open="closeDetail"
|
||||
>
|
||||
<div
|
||||
v-if="detailLoading"
|
||||
role="status"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
加载中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detailError"
|
||||
role="alert"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ detailError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detail"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full"
|
||||
:class="statusColor(detail.status)"
|
||||
/>{{ statusLabel(detail.status) }}
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-muted-foreground">
|
||||
服务可用率
|
||||
</dt><dd class="mt-1 font-medium">
|
||||
{{ percentage(detail.service_availability.value) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
最终请求成功率
|
||||
</dt><dd class="mt-1 font-medium">
|
||||
{{ percentage(detail.request_success.value) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
有效服务样本
|
||||
</dt><dd class="mt-1">
|
||||
{{ detail.service_availability.denominator.toLocaleString() }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
已排除请求
|
||||
</dt><dd class="mt-1">
|
||||
{{ detail.coverage.excluded_count.toLocaleString() }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
未分类失败
|
||||
</dt><dd class="mt-1">
|
||||
{{ detail.coverage.unknown_failure_count.toLocaleString() }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
最后请求
|
||||
</dt><dd class="mt-1">
|
||||
{{ detail.last_request_at ? dateTime(detail.last_request_at) : '无流量' }}
|
||||
</dd>
|
||||
</div><template v-if="adminDetail">
|
||||
<div>
|
||||
<dt class="text-muted-foreground">
|
||||
渠道尝试成功率
|
||||
</dt><dd class="mt-1">
|
||||
{{ percentage(adminDetail.attempts.success.value) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-muted-foreground">
|
||||
执行中渠道尝试
|
||||
</dt><dd class="mt-1">
|
||||
{{ adminDetail.attempts.in_progress_count }}
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
</dl>
|
||||
<div class="flex h-10 gap-1">
|
||||
<span
|
||||
v-for="(bucket, index) in detail.timeline"
|
||||
:key="index"
|
||||
class="min-w-1 flex-1 rounded-sm"
|
||||
:class="statusColor(bucket.status)"
|
||||
:title="`${dateTime(bucket.from)}: ${percentage(bucket.service_availability.value)}`"
|
||||
/>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-if="adminDetail"
|
||||
:to="usageLink"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-primary"
|
||||
>
|
||||
使用记录<ArrowUpRight class="h-4 w-4" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
v-model:open="publicationOpen"
|
||||
title="状态页发布(/status)"
|
||||
max-width="3xl"
|
||||
>
|
||||
<div
|
||||
v-if="publicationLoading"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
加载中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="publication"
|
||||
class="space-y-4"
|
||||
>
|
||||
<label class="flex items-center gap-2 text-sm font-medium"><input
|
||||
v-model="publication.enabled"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-primary"
|
||||
>启用 /status 状态页</label>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[560px] text-left text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-muted-foreground">
|
||||
<th class="pb-2 font-medium">
|
||||
类型
|
||||
</th><th class="pb-2 font-medium">
|
||||
监控对象
|
||||
</th><th class="pb-2 font-medium">
|
||||
公开 ID
|
||||
</th><th class="pb-2 font-medium">
|
||||
公开名称
|
||||
</th><th />
|
||||
</tr>
|
||||
</thead><tbody>
|
||||
<tr
|
||||
v-for="(object, index) in publication.objects"
|
||||
:key="index"
|
||||
>
|
||||
<td class="py-2 pr-2">
|
||||
<select
|
||||
v-model="object.kind"
|
||||
class="h-8 w-24 rounded-md border border-input bg-background px-2"
|
||||
>
|
||||
<option value="api_format">
|
||||
API 格式
|
||||
</option><option value="model">
|
||||
模型
|
||||
</option>
|
||||
</select>
|
||||
</td><td class="pr-2">
|
||||
<input
|
||||
v-model="object.value"
|
||||
aria-label="监控对象"
|
||||
class="h-8 w-full rounded-md border border-input bg-background px-2"
|
||||
maxlength="256"
|
||||
>
|
||||
</td><td class="pr-2">
|
||||
<input
|
||||
v-model="object.public_id"
|
||||
aria-label="公开 ID"
|
||||
class="h-8 w-full rounded-md border border-input bg-background px-2"
|
||||
maxlength="80"
|
||||
>
|
||||
</td><td class="pr-2">
|
||||
<input
|
||||
v-model="object.display_name"
|
||||
aria-label="公开名称"
|
||||
class="h-8 w-full rounded-md border border-input bg-background px-2"
|
||||
maxlength="120"
|
||||
>
|
||||
</td><td>
|
||||
<button
|
||||
type="button"
|
||||
title="移除公开对象"
|
||||
aria-label="移除公开对象"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted"
|
||||
@click="publication.objects.splice(index, 1)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-sm text-primary"
|
||||
@click="publication.objects.push({ public_id: '', kind: 'api_format', value: '', display_name: '' })"
|
||||
>
|
||||
<Plus class="h-4 w-4" />添加对象
|
||||
</button>
|
||||
<div
|
||||
v-if="publicationError"
|
||||
role="alert"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ publicationError }}
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-border pt-4">
|
||||
<RouterLink
|
||||
to="/status"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
公开状态页<ArrowUpRight class="h-4 w-4" />
|
||||
</RouterLink><button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm text-primary-foreground disabled:opacity-50"
|
||||
:disabled="savingPublication"
|
||||
@click="savePublication"
|
||||
>
|
||||
<Save class="h-4 w-4" />保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="publicationError"
|
||||
role="alert"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ publicationError }}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AlertTriangle, ArrowUpRight, ChevronLeft, ChevronRight, Globe, LoaderCircle, Plus, RefreshCw, Save, Trash2 } from 'lucide-vue-next'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import {
|
||||
getAdminHealthObject, getAdminHealthObjects, getAdminHealthSummary, getHealthPublication,
|
||||
getPublicHealthObject, getPublicHealthObjects, getPublicHealthSummaryV2, saveHealthPublication,
|
||||
getUserHealthObject, getUserHealthObjects, getUserHealthSummary,
|
||||
type AdminHealthObject, type HealthMeta, type HealthObjectKind, type HealthObjectsPage,
|
||||
type HealthPublication, type HealthQuery, type HealthSummaryV2, type HealthWindow,
|
||||
type PublicHealthObject, type PublicHealthQuery, type ServiceHealthStatus,
|
||||
} from '@/api/endpoints/health-v2'
|
||||
|
||||
const props = withDefaults(defineProps<{ isAdmin?: boolean; publicPage?: boolean }>(), { isAdmin: false, publicPage: false })
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tabs = computed(() => [
|
||||
{ kind: 'api_format' as const, label: 'API 格式' },
|
||||
{ kind: 'model' as const, label: '模型' },
|
||||
...(props.isAdmin ? [{ kind: 'provider' as const, label: '提供商' }] : []),
|
||||
])
|
||||
const kind = computed<HealthObjectKind>(() => tabs.value.find(tab => tab.kind === route.query.kind)?.kind ?? 'api_format')
|
||||
const window = computed<HealthWindow>(() => ['1h', '6h', '24h', '72h'].includes(String(route.query.window)) ? route.query.window as HealthWindow : '6h')
|
||||
const offset = computed(() => { const value = Number(route.query.offset); return Number.isInteger(value) && value >= 0 && value <= 10_000 ? value : 0 })
|
||||
const selectedId = computed(() => typeof route.query.object === 'string' ? route.query.object : '')
|
||||
const activeLabel = computed(() => tabs.value.find(tab => tab.kind === kind.value)?.label)
|
||||
const summary = ref<HealthSummaryV2 | null>(null)
|
||||
const page = ref<HealthObjectsPage<PublicHealthObject | AdminHealthObject> | null>(null)
|
||||
const meta = ref<HealthMeta | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const detail = ref<PublicHealthObject | AdminHealthObject | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref('')
|
||||
const adminDetail = computed(() => detail.value && 'attempts' in detail.value ? detail.value as AdminHealthObject : null)
|
||||
let controller: AbortController | null = null
|
||||
let detailController: AbortController | null = null
|
||||
let loadVersion = 0
|
||||
let detailVersion = 0
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const query = computed<HealthQuery>(() => ({ kind: kind.value, window: window.value, limit: 25, offset: offset.value }))
|
||||
function setQuery(patch: Record<string, string | undefined>, push = false) { void router[push ? 'push' : 'replace']({ query: { ...route.query, ...patch } }) }
|
||||
function changeWindow(event: Event) { setQuery({ window: (event.target as HTMLSelectElement).value, offset: undefined, object: undefined }) }
|
||||
function selectObject(id: string) { setQuery({ object: id }, true) }
|
||||
function closeDetail(open: boolean) { if (!open) setQuery({ object: undefined }) }
|
||||
function percentage(value: number | null) { return value == null ? '未知' : `${(value * 100).toFixed(2)}%` }
|
||||
function dateTime(value: string) { return new Date(value).toLocaleString(undefined, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }
|
||||
function statusLabel(status: ServiceHealthStatus) { return { healthy: '正常', degraded: '波动', unavailable: '异常', unknown: '未知' }[status] }
|
||||
function statusColor(status: ServiceHealthStatus) { return { healthy: 'bg-emerald-500', degraded: 'bg-amber-500', unavailable: 'bg-red-500', unknown: 'bg-muted-foreground/25' }[status] }
|
||||
async function load() {
|
||||
const version = ++loadVersion
|
||||
controller?.abort()
|
||||
clearTimeout(refreshTimer)
|
||||
controller = new AbortController()
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [nextSummary, nextPage] = props.isAdmin
|
||||
? await Promise.all([getAdminHealthSummary(query.value, controller.signal), getAdminHealthObjects(query.value, controller.signal)])
|
||||
: props.publicPage
|
||||
? await Promise.all([getPublicHealthSummaryV2(query.value as PublicHealthQuery, controller.signal), getPublicHealthObjects(query.value as PublicHealthQuery, controller.signal)])
|
||||
: await Promise.all([getUserHealthSummary(query.value as PublicHealthQuery, controller.signal), getUserHealthObjects(query.value as PublicHealthQuery, controller.signal)])
|
||||
if (version !== loadVersion) return
|
||||
summary.value = nextSummary.data
|
||||
page.value = nextPage.data
|
||||
meta.value = nextSummary.meta
|
||||
} catch (cause) {
|
||||
if (version !== loadVersion || controller.signal.aborted) return
|
||||
const status = (cause as { response?: { status?: number } }).response?.status
|
||||
error.value = status === 404 && props.publicPage ? '公开状态页尚未启用' : '健康数据暂时不可用,请稍后刷新。'
|
||||
} finally { if (version === loadVersion) { loading.value = false; refreshTimer = setTimeout(load, 60_000) } }
|
||||
}
|
||||
async function loadDetail() {
|
||||
const version = ++detailVersion
|
||||
detailController?.abort()
|
||||
detail.value = null
|
||||
detailError.value = ''
|
||||
if (!selectedId.value) { detailLoading.value = false; return }
|
||||
detailController = new AbortController()
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const result = props.isAdmin
|
||||
? await getAdminHealthObject(selectedId.value, query.value, detailController.signal)
|
||||
: props.publicPage
|
||||
? await getPublicHealthObject(selectedId.value, query.value as PublicHealthQuery, detailController.signal)
|
||||
: await getUserHealthObject(selectedId.value, query.value as PublicHealthQuery, detailController.signal)
|
||||
if (version === detailVersion) detail.value = result.data
|
||||
} catch { if (version === detailVersion && !detailController.signal.aborted) detailError.value = '对象详情暂时不可用' }
|
||||
finally { if (version === detailVersion) detailLoading.value = false }
|
||||
}
|
||||
const usageLink = computed(() => ({ path: '/admin/usage', query: {
|
||||
from: meta.value?.range.from, to: meta.value?.range.to, timezone: 'UTC',
|
||||
[kind.value === 'model' ? 'model' : kind.value === 'provider' ? 'provider_id' : 'api_format']: adminDetail.value?.source_value,
|
||||
} }))
|
||||
const publicationOpen = ref(false)
|
||||
const publicationLoading = ref(false)
|
||||
const publication = ref<HealthPublication | null>(null)
|
||||
const publicationError = ref('')
|
||||
const savingPublication = ref(false)
|
||||
async function openPublication() {
|
||||
publicationOpen.value = true; publicationLoading.value = true; publicationError.value = ''
|
||||
try { publication.value = await getHealthPublication() }
|
||||
catch { publicationError.value = '状态页发布配置暂时不可用' }
|
||||
finally { publicationLoading.value = false }
|
||||
}
|
||||
async function savePublication() {
|
||||
if (!publication.value) return
|
||||
savingPublication.value = true; publicationError.value = ''
|
||||
try { publication.value = await saveHealthPublication(publication.value); publicationOpen.value = false }
|
||||
catch (cause) { publicationError.value = (cause as { response?: { data?: { detail?: string } } }).response?.data?.detail || '保存失败,请稍后重试' }
|
||||
finally { savingPublication.value = false }
|
||||
}
|
||||
watch([kind, window, offset, () => props.isAdmin, () => props.publicPage], () => { summary.value = null; page.value = null; void load() }, { immediate: true })
|
||||
watch([selectedId, kind, window], loadDetail, { immediate: true })
|
||||
onBeforeUnmount(() => { ++loadVersion; ++detailVersion; controller?.abort(); detailController?.abort(); clearTimeout(refreshTimer) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.health-object-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.health-object-table td:first-child {
|
||||
min-width: 160px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
|
||||
import HealthMonitorView from '../HealthMonitorView.vue'
|
||||
import type { HealthMeta, PublicHealthObject } from '@/api/endpoints/health-v2'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getAdminHealthSummary: vi.fn(), getAdminHealthObjects: vi.fn(), getAdminHealthObject: vi.fn(),
|
||||
getPublicHealthSummaryV2: vi.fn(), getPublicHealthObjects: vi.fn(), getPublicHealthObject: vi.fn(),
|
||||
getUserHealthSummary: vi.fn(), getUserHealthObjects: vi.fn(), getUserHealthObject: vi.fn(),
|
||||
getHealthPublication: vi.fn(), saveHealthPublication: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/api/endpoints/health-v2', () => api)
|
||||
vi.mock('@/components/ui/dialog/Dialog.vue', () => ({
|
||||
default: defineComponent({ props: { open: Boolean, title: String }, setup: (props, { slots }) => () => props.open ? h('section', [h('h2', props.title), slots.default?.()]) : null }),
|
||||
}))
|
||||
|
||||
const ratio = { numerator: 100, denominator: 100, value: 1 }
|
||||
const meta: HealthMeta = {
|
||||
schema_version: 2, metric_version: 'service-health-v1', scope: { kind: 'published', object_kind: 'api_format' },
|
||||
range: { from: '2026-09-11T00:00:00Z', to: '2026-09-11T06:00:00Z', timezone: 'UTC', time_basis: 'request_started_at' },
|
||||
generated_at: '2026-09-11T06:00:00Z', data_through: null, freshness: 'unknown',
|
||||
policy: { version: 'service-health-v1', minimum_samples: 20, healthy_threshold: 0.99, degraded_threshold: 0.95 },
|
||||
}
|
||||
function object(name = 'Chat API'): PublicHealthObject {
|
||||
return { id: 'chat', kind: 'api_format', name, status: 'healthy', request_count: 100,
|
||||
request_success: ratio, service_availability: ratio,
|
||||
coverage: { status: 'complete', sample_status: 'sufficient', classified_count: 100, excluded_count: 0, unknown_failure_count: 0, exclusion_policy: 'policy' },
|
||||
average_latency_ms: 450, latency_sample_count: 100, last_request_at: null, timeline: [],
|
||||
}
|
||||
}
|
||||
function summary() { return { meta, data: { status: 'healthy', object_count: 40, healthy_count: 40, degraded_count: 0, unavailable_count: 0, unknown_count: 0, requests: object() } } }
|
||||
function page(name = 'Chat API') { return { meta, data: { items: [object(name)], total: 40, limit: 25, offset: 0 } } }
|
||||
const mounted: Array<{ app: App; root: HTMLElement }> = []
|
||||
async function flush() { for (let i = 0; i < 8; i++) { await Promise.resolve(); await nextTick() } }
|
||||
async function mountView(admin = false, publicPage = !admin): Promise<{ root: HTMLElement; router: Router }> {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ setup: () => () => h('div') }) }] })
|
||||
await router.push(admin ? '/admin/health' : '/status')
|
||||
await router.isReady()
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(HealthMonitorView, { isAdmin: admin, publicPage })
|
||||
app.use(router).mount(root)
|
||||
mounted.push({ app, root })
|
||||
await flush()
|
||||
return { root, router }
|
||||
}
|
||||
function button(root: HTMLElement, text: string) { return Array.from(root.querySelectorAll('button')).find(button => button.textContent?.includes(text))! }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getAdminHealthSummary.mockResolvedValue(summary())
|
||||
api.getPublicHealthSummaryV2.mockResolvedValue(summary())
|
||||
api.getAdminHealthObjects.mockResolvedValue(page())
|
||||
api.getPublicHealthObjects.mockResolvedValue(page())
|
||||
api.getUserHealthSummary.mockResolvedValue(summary())
|
||||
api.getUserHealthObjects.mockResolvedValue(page())
|
||||
api.getAdminHealthObject.mockResolvedValue({ meta, data: object() })
|
||||
api.getPublicHealthObject.mockResolvedValue({ meta, data: object() })
|
||||
api.getHealthPublication.mockResolvedValue({ enabled: false, objects: [] })
|
||||
})
|
||||
afterEach(() => { for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() } })
|
||||
|
||||
describe('independent health monitor', () => {
|
||||
it('keeps authenticated health independent of the public publication switch', async () => {
|
||||
const { root } = await mountView(false, false)
|
||||
expect(api.getUserHealthSummary).toHaveBeenCalledOnce()
|
||||
expect(api.getUserHealthObjects).toHaveBeenCalledOnce()
|
||||
expect(api.getPublicHealthSummaryV2).not.toHaveBeenCalled()
|
||||
expect(api.getAdminHealthSummary).not.toHaveBeenCalled()
|
||||
expect(root.textContent).toContain('Chat API')
|
||||
})
|
||||
it('uses only public queries and renders server totals independently of page rows', async () => {
|
||||
const { root, router } = await mountView()
|
||||
expect(api.getAdminHealthSummary).not.toHaveBeenCalled()
|
||||
expect(api.getAdminHealthObjects).not.toHaveBeenCalled()
|
||||
expect(root.textContent).toContain('40 个对象')
|
||||
expect(root.textContent).toContain('Chat API')
|
||||
expect(root.textContent).not.toContain('状态页发布')
|
||||
expect(root.textContent).not.toContain('提供商')
|
||||
const navigated = new Promise<void>(resolve => { const stop = router.afterEach(() => { stop(); resolve() }) })
|
||||
button(root, '模型').click()
|
||||
await navigated
|
||||
await flush()
|
||||
expect(router.currentRoute.value.query.kind).toBe('model')
|
||||
expect(api.getPublicHealthSummaryV2).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'model' }), expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('retains previous data when a manual refresh fails', async () => {
|
||||
const { root } = await mountView()
|
||||
api.getPublicHealthSummaryV2.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
root.querySelector<HTMLButtonElement>('[aria-label="刷新健康数据"]')!.click()
|
||||
await flush()
|
||||
expect(root.textContent).toContain('健康数据暂时不可用')
|
||||
expect(root.textContent).toContain('Chat API')
|
||||
})
|
||||
|
||||
it('keeps changed filters when an older request resolves late', async () => {
|
||||
let resolveOld!: (value: ReturnType<typeof page>) => void
|
||||
api.getPublicHealthObjects.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
|
||||
const { root, router } = await mountView()
|
||||
await router.push('/status?kind=model')
|
||||
await flush()
|
||||
resolveOld(page('Obsolete data'))
|
||||
await flush()
|
||||
expect(root.textContent).toContain('Chat API')
|
||||
expect(root.textContent).not.toContain('Obsolete data')
|
||||
})
|
||||
|
||||
it('shows publication save failures without dismissing the editor', async () => {
|
||||
const { root } = await mountView(true)
|
||||
button(root, '状态页发布').click()
|
||||
await flush()
|
||||
api.saveHealthPublication.mockRejectedValueOnce({ response: { data: { detail: 'Duplicate public ID' } } })
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(api.saveHealthPublication).toHaveBeenCalledWith({ enabled: false, objects: [] })
|
||||
expect(root.textContent).toContain('Duplicate public ID')
|
||||
expect(root.textContent).toContain('状态页发布(/status)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { analysisShare, analyzeRows, mergeLiveAnalysis, rankAnalysisRows, type AnalysisRow } from '../operations/analysisHelpers'
|
||||
|
||||
function row(id: string, requestCount: number, failedCount: number | null): AnalysisRow {
|
||||
return { id, label: id, requestCount, failedCount, successRate: null, firstByteMs: null, outputTps: null }
|
||||
}
|
||||
|
||||
describe('automatic provider and model analysis', () => {
|
||||
it('merges observed load without deriving RPM or concurrency from historical request counts', () => {
|
||||
const history = [row('busy', 50_000, 12), row('idle', 8_000, 0)]
|
||||
const merged = mergeLiveAnalysis(history, [{ id: 'busy', label: 'busy', concurrency: 7, rpm: 83 }])
|
||||
expect(merged).toEqual([
|
||||
{ ...history[0], concurrency: 7, rpm: 83 },
|
||||
{ ...history[1], concurrency: 0, rpm: 0 },
|
||||
])
|
||||
expect(history[0].concurrency).toBeUndefined()
|
||||
expect(mergeLiveAnalysis(history, null)).toEqual(history.map(item => ({ ...item, concurrency: null, rpm: null })))
|
||||
expect(mergeLiveAnalysis(history, [])).toEqual(history.map(item => ({ ...item, concurrency: 0, rpm: 0 })))
|
||||
})
|
||||
|
||||
it('includes active entities before their requests are recorded without inventing historical rates', () => {
|
||||
expect(mergeLiveAnalysis([], [{ id: 'new-model', label: 'New model', concurrency: 3, rpm: 9 }])).toEqual([{
|
||||
id: 'new-model', label: 'New model', requestCount: 0, failedCount: 0,
|
||||
successRate: null, firstByteMs: null, outputTps: null, concurrency: 3, rpm: 9,
|
||||
}])
|
||||
expect(mergeLiveAnalysis(null, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('prioritizes the largest failure impact, then traffic, and preserves unknown failure counts', () => {
|
||||
const rows = [row('unknown', 9000, null), row('healthy', 5000, 0), row('b', 200, 8), row('a', 200, 8), row('more-traffic', 300, 8), row('most-failures', 50, 12)]
|
||||
expect(rankAnalysisRows(rows).map(item => item.id)).toEqual(['most-failures', 'more-traffic', 'a', 'b', 'healthy', 'unknown'])
|
||||
expect(rows[0].id).toBe('unknown')
|
||||
})
|
||||
|
||||
it('computes each leader independently and uses the complete request and failure totals', () => {
|
||||
const result = analyzeRows([row('busy', 900, 1), row('failing', 100, 9)], 1000, 10)
|
||||
expect(result.traffic).toMatchObject({ state: 'ready', row: { id: 'busy' }, share: 90 })
|
||||
expect(result.failures).toMatchObject({ state: 'ready', row: { id: 'failing' }, share: 90 })
|
||||
})
|
||||
|
||||
it('never invents a percentage for missing, empty or inconsistent denominators', () => {
|
||||
expect(analysisShare(0, 10)).toBe(0)
|
||||
expect(analysisShare(1, 4)).toBe(25)
|
||||
for (const [part, total] of [[0, 0], [1, null], [null, 10], [NaN, 10], [1, Infinity], [11, 10], [-1, 10], [1, -10]]) {
|
||||
expect(analysisShare(part, total)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('distinguishes no requests, measured no failures, missing analysis and missing failures', () => {
|
||||
expect(analyzeRows([], 0, 0).traffic.state).toBe('empty')
|
||||
expect(analyzeRows([], 0, 0).failures.state).toBe('empty')
|
||||
expect(analyzeRows([row('busy', 20, 0)], 20, 0).failures.state).toBe('none')
|
||||
expect(analyzeRows(null, 20, 0).failures.state).toBe('unavailable')
|
||||
expect(analyzeRows([row('busy', 20, null)], 20, null).failures.state).toBe('unavailable')
|
||||
expect(analyzeRows([], 20, 5).traffic.state).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('labels an observed failure leader as partial when other rows have no failure counts', () => {
|
||||
const result = analyzeRows([row('unknown', 100, null), row('measured', 10, 3)], 110, null)
|
||||
expect(result.failures).toMatchObject({ state: 'ready', row: { id: 'measured' }, share: null, partial: true })
|
||||
})
|
||||
|
||||
it('uses stable names for unidentified entities and does not drop rows beyond a top ten', () => {
|
||||
const rows = Array.from({ length: 12 }, (_, index) => ({ ...row(`model-${index}`, 10, 0), id: null }))
|
||||
const ranked = rankAnalysisRows(rows.reverse())
|
||||
expect(ranked).toHaveLength(12)
|
||||
expect(ranked[0].label).toBe('model-0')
|
||||
expect(analyzeRows(rows, 120, 0).traffic.row?.label).toBe('model-0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CostAnalysis from '@/views/admin/CostAnalysis.vue'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
const overview = vi.hoisted(() => ({ costs: vi.fn(), breakdown: vi.fn(), exportCsv: vi.fn() }))
|
||||
const finance = vi.hoisted(() => ({ accounts: vi.fn(), expenses: vi.fn(), record: vi.fn(), void: vi.fn(), exportExpenses: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: overview }))
|
||||
vi.mock('@/api/providerFinance', () => ({ providerFinanceApi: finance }))
|
||||
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
|
||||
|
||||
const range = { from: '2026-09-01T00:00:00.000Z', to: '2026-09-02T00:00:00.000Z', timezone: 'UTC' }
|
||||
const metric = { request_count: 1357, input_tokens: 5000, output_tokens: 678, total_tokens: 5678, billable_amount: { value: '999999', currency: 'USD', basis: 'billable', status: 'known' } }
|
||||
const expense = { id: 'expense-1', provider_id: 'provider-1', provider_name: 'Provider One', kind: 'recharge', amount: '1.23', currency: 'USD', paid_at: range.from, period_start: null, period_end: null, note: 'Invoice one', external_reference: 'invoice-123', status: 'recorded', created_at: range.from, created_by: 'admin', voided_at: null }
|
||||
const totals = [{ currency: 'USD', amount: '432.10', recharge_amount: '400', subscription_amount: '32.10', other_amount: '0', entry_count: 20 }, { currency: 'EUR', amount: '87.65', recharge_amount: '20', subscription_amount: '67.65', other_amount: '0', entry_count: 20 }]
|
||||
const account = { provider_id: 'provider-1', provider_name: 'Provider One', is_active: true, billing_type: 'monthly_quota', quota: { limit: 100, used: 25, remaining: 75, currency: 'USD', period_start: range.from, expires_at: '2026-10-01T00:00:00Z' }, balance: { status: 'success', observed_at: range.to, currency: 'USD', available: 55, used: 45, granted: 100, plan_name: 'Monthly plan', subscriptions: [] } }
|
||||
const mounted: { app: App; root: HTMLElement }[] = []
|
||||
async function settle() { await Promise.resolve(); await Promise.resolve(); await nextTick(); await new Promise(resolve => setTimeout(resolve, 0)) }
|
||||
async function mount(extra = '') {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/cost-analysis', component: CostAnalysis }, { path: '/admin/usage', component: { render: () => null } }] })
|
||||
await router.push({ path: '/admin/cost-analysis', query: { ...range, ...(extra ? { provider_id: extra, model: 'legacy-model', amount_basis: 'billable', group_by: 'model' } : {}) } })
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(CostAnalysis).use(router)
|
||||
app.mount(root)
|
||||
mounted.push({ app, root })
|
||||
await settle()
|
||||
return { root, router }
|
||||
}
|
||||
function section(root: ParentNode, selector: string) {
|
||||
const item = root.querySelector(selector)
|
||||
if (!item) throw new Error(`Missing section ${selector}`)
|
||||
return item
|
||||
}
|
||||
function button(root: ParentNode, name: string) {
|
||||
const item = [...root.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === name)
|
||||
if (!item) throw new Error(`Missing button ${name}`)
|
||||
return item
|
||||
}
|
||||
function input(label: string) {
|
||||
const item = [...document.body.querySelectorAll('label')].find(item => item.textContent?.includes(label))?.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('input,select,textarea')
|
||||
if (!item) throw new Error(`Missing input ${label}`)
|
||||
return item
|
||||
}
|
||||
function fill(label: string, value: string) {
|
||||
const field = input(label)
|
||||
field.value = value
|
||||
field.dispatchEvent(new Event(field.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
|
||||
}
|
||||
async function submitExpense() {
|
||||
section(document.body, '#provider-expense-form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await settle()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
setI18nLocale('zh-CN')
|
||||
overview.costs.mockResolvedValue({ data: { summary: metric, timeseries: [{ ...metric, bucket_start: range.from }] } })
|
||||
overview.breakdown.mockImplementation(async query => ({ data: { items: [{ ...metric, id: 'provider-1', label: 'Provider One' }], total: 40, limit: query.limit, offset: query.offset } }))
|
||||
finance.accounts.mockResolvedValue({ observed_at: range.to, items: [account] })
|
||||
finance.expenses.mockImplementation(async query => ({ items: [expense], total: 40, limit: query.limit, offset: query.offset, totals, providers: [{ provider_id: 'provider-1', provider_name: 'Provider One', currency: 'USD', amount: '432.10', entry_count: 20 }] }))
|
||||
finance.record.mockResolvedValue({ item: expense })
|
||||
finance.void.mockResolvedValue({ item: { ...expense, status: 'void' } })
|
||||
})
|
||||
afterEach(() => { mounted.splice(0).forEach(({ app, root }) => { app.unmount(); root.remove() }); setI18nLocale('zh-CN') })
|
||||
|
||||
describe('provider expense analysis', () => {
|
||||
it('uses complete expense totals and keeps currencies separate from customer consumption', async () => {
|
||||
const { root } = await mount('ignored-provider')
|
||||
const summary = section(root, '[data-cost-summary]').textContent
|
||||
expect(summary).toContain('432.10 USD')
|
||||
expect(summary).toContain('87.65 EUR')
|
||||
expect(summary).not.toContain('1.23')
|
||||
expect(summary).not.toContain('999,999')
|
||||
expect(summary).not.toContain('519.75')
|
||||
expect(summary).toContain('1,357')
|
||||
expect(overview.costs.mock.lastCall?.[0]).not.toHaveProperty('provider_id')
|
||||
expect(overview.breakdown.mock.lastCall?.[0]).toMatchObject({ group_by: 'provider', sort: 'request_count' })
|
||||
expect(overview.breakdown.mock.lastCall?.[0]).not.toHaveProperty('model')
|
||||
expect(finance.expenses.mock.lastCall?.[0]).not.toHaveProperty('provider_id')
|
||||
})
|
||||
|
||||
it.each(['costs', 'providers', 'accounts', 'expenses'] as const)('keeps other sections available when %s fails', async failed => {
|
||||
const call = { costs: overview.costs, providers: overview.breakdown, accounts: finance.accounts, expenses: finance.expenses }[failed]
|
||||
call.mockRejectedValue(new Error(`${failed} unavailable`))
|
||||
const { root } = await mount()
|
||||
expect(overview.costs).toHaveBeenCalledTimes(1)
|
||||
expect(overview.breakdown).toHaveBeenCalledTimes(1)
|
||||
expect(finance.accounts).toHaveBeenCalledTimes(1)
|
||||
expect(finance.expenses).toHaveBeenCalledTimes(1)
|
||||
expect(root.textContent).toContain(`${failed} unavailable`)
|
||||
if (failed !== 'providers') expect(section(root, '[data-provider-usage]').textContent).toContain('Provider One')
|
||||
if (failed !== 'accounts') expect(section(root, '[data-provider-accounts]').textContent).toContain('Monthly plan')
|
||||
if (failed !== 'expenses') expect(section(root, '[data-cost-summary]').textContent).toContain('432.10 USD')
|
||||
if (failed === 'expenses') expect(section(root, '[data-cost-summary]').textContent).not.toContain('999,999')
|
||||
})
|
||||
|
||||
it('retains expense totals during pagination and clears them for another date scope', async () => {
|
||||
const { root, router } = await mount()
|
||||
finance.expenses.mockImplementation(() => new Promise(() => {}))
|
||||
section(root, '[data-provider-expenses]').querySelector<HTMLButtonElement>('[aria-label="下一页"]')?.click()
|
||||
await settle()
|
||||
expect(finance.expenses.mock.lastCall?.[0]).toMatchObject({ offset: 25 })
|
||||
expect(section(root, '[data-cost-summary]').textContent).toContain('432.10 USD')
|
||||
expect(overview.costs).toHaveBeenCalledTimes(1)
|
||||
expect(overview.breakdown).toHaveBeenCalledTimes(1)
|
||||
await router.replace({ query: { from: '2026-08-01T00:00:00Z', to: '2026-08-02T00:00:00Z', timezone: 'UTC' } })
|
||||
await settle()
|
||||
expect(finance.expenses.mock.lastCall?.[0]).toMatchObject({ offset: 0, from: '2026-08-01T00:00:00.000Z' })
|
||||
expect(section(root, '[data-cost-summary]').textContent).not.toContain('432.10 USD')
|
||||
expect(section(root, '[data-provider-expenses]').textContent).not.toContain('invoice-123')
|
||||
})
|
||||
|
||||
it('reuses a retry id for unchanged payment data and refreshes expenses after a successful save', async () => {
|
||||
finance.record.mockRejectedValue(new Error('connection lost'))
|
||||
const { root } = await mount()
|
||||
button(root, '登记支出').click()
|
||||
await settle()
|
||||
fill('提供商', 'provider-1')
|
||||
fill('实付金额', '10.25')
|
||||
fill('付款时间', '2026-09-01T13:20')
|
||||
await submitExpense()
|
||||
expect(finance.record).toHaveBeenCalledTimes(1)
|
||||
const original = finance.record.mock.lastCall?.[0]
|
||||
expect(original).toMatchObject({ amount: '10.25', provider_id: 'provider-1', currency: 'USD', paid_at: '2026-09-01T13:20:00.000Z' })
|
||||
expect(original.client_request_id).toBeTruthy()
|
||||
expect(document.body.textContent).toContain('connection lost')
|
||||
await submitExpense()
|
||||
expect(finance.record.mock.lastCall?.[0].client_request_id).toBe(original.client_request_id)
|
||||
fill('实付金额', '11.25')
|
||||
finance.record.mockResolvedValue({ item: { ...expense, amount: '11.25' } })
|
||||
await submitExpense()
|
||||
expect(finance.record.mock.lastCall?.[0].client_request_id).not.toBe(original.client_request_id)
|
||||
expect(finance.expenses).toHaveBeenCalledTimes(2)
|
||||
expect(document.body.querySelector('#provider-expense-form')).toBeNull()
|
||||
})
|
||||
|
||||
it('requires a confirmation before voiding and refreshes the ledger only after confirmation', async () => {
|
||||
const { root } = await mount()
|
||||
button(section(root, '[data-provider-expenses]'), '作废').click()
|
||||
await settle()
|
||||
expect(finance.void).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('确认作废这笔记录')
|
||||
button(document.body, '取消').click()
|
||||
await settle()
|
||||
expect(finance.void).not.toHaveBeenCalled()
|
||||
button(section(root, '[data-provider-expenses]'), '作废').click()
|
||||
await settle()
|
||||
button(document.body, '确认作废').click()
|
||||
await settle()
|
||||
expect(finance.void).toHaveBeenCalledWith('expense-1')
|
||||
expect(finance.expenses).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import { createPinia, disposePinia, type Pinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import AdminDashboard from '@/views/admin/AdminDashboard.vue'
|
||||
import type { OverviewDashboardCharts, OverviewMetrics } from '@/api/overview'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
import { dayRange, modelDatasets, providerSlices } from '../dashboard/charts'
|
||||
import { dashboardSummary } from './fixtures/dashboardSummary'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
|
||||
const api = vi.hoisted(() => ({ dashboardSummary: vi.fn(), summary: vi.fn(), dashboardTotal: vi.fn(), dashboardCharts: vi.fn() }))
|
||||
const legacyApi = vi.hoisted(() => ({ getStats: vi.fn(), getDailyStats: vi.fn() }))
|
||||
const announcementApi = vi.hoisted(() => ({ getAnnouncements: vi.fn(), getUserAnnouncements: vi.fn(), markAsRead: vi.fn() }))
|
||||
const auth = vi.hoisted(() => ({ canAccessAdmin: true, isAdmin: true, isAuditAdmin: false }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('@/api/dashboard', () => ({ dashboardApi: legacyApi }))
|
||||
vi.mock('@/features/usage/components/IntervalTimelineCard.vue', async () => { const { h, defineComponent } = await import('vue'); return { default: defineComponent({ props: { title: String, hours: Number, isAdmin: Boolean, refreshIntervalMs: Number }, setup: props => () => h('div', { 'data-timeline': '' }, props.title) }) } })
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: () => auth }))
|
||||
vi.mock('@/api/announcements', () => ({ announcementApi }))
|
||||
vi.mock('@/components/charts/BarChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'bar' }) } } })
|
||||
vi.mock('@/components/charts/DoughnutChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'doughnut' }) } } })
|
||||
vi.mock('@/components/charts/LineChart.vue', async () => { const { h } = await import('vue'); return { default: { render: () => h('div', { 'data-chart': 'line' }) } } })
|
||||
vi.mock('@/components/common', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return { TimeRangePicker: defineComponent({
|
||||
props: { modelValue: { type: Object, default: () => ({}) } }, emits: ['update:modelValue'],
|
||||
setup: (props, { emit }) => () => h('button', { 'data-period-control': '', onClick: () => emit('update:modelValue', { ...props.modelValue, preset: 'last30days' }) }, 'Last 30 days'),
|
||||
}) }
|
||||
})
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const passthrough = (name: string, tag = 'div') => defineComponent({ name, setup: (_, { slots }) => () => h(tag, slots.default?.()) })
|
||||
return {
|
||||
Card: passthrough('CardStub', 'section'), Badge: passthrough('BadgeStub', 'span'), Button: passthrough('ButtonStub', 'button'),
|
||||
Skeleton: passthrough('SkeletonStub'),
|
||||
Table: passthrough('TableStub', 'table'), TableHeader: passthrough('TableHeaderStub', 'thead'), TableBody: passthrough('TableBodyStub', 'tbody'), TableRow: passthrough('TableRowStub', 'tr'), TableHead: passthrough('TableHeadStub', 'th'), TableCell: passthrough('TableCellStub', 'td'),
|
||||
}
|
||||
})
|
||||
|
||||
const amount = (value: string | null, status = value === null ? 'unknown' : 'known') => ({ value, status, currency: 'USD', basis: 'billable' })
|
||||
const metrics = (request_count = 0): OverviewMetrics => ({
|
||||
request_count, successful_request_count: request_count, failed_request_count: 0, cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: 0,
|
||||
input_tokens: request_count * 10, output_tokens: request_count * 10, total_tokens: request_count * 20, usage_active_users: 3, enabled_users: 8,
|
||||
success_rate: { value: request_count ? 1 : null, numerator: request_count, denominator: request_count }, latency_ms: { avg: 100, p50: 90, p95: 190, p99: 230, sample_count: request_count },
|
||||
rated_amount: amount('10'), billable_amount: amount('5'), quota_covered_amount: amount('2'), wallet_consumed_amount: amount('3'), wallet_debit_amount: amount('3'),
|
||||
})
|
||||
const charts = (): OverviewDashboardCharts => ({
|
||||
summary: metrics(777), series: [{ ...metrics(9), bucket_start: '2026-09-05T00:00:00Z' }],
|
||||
models: [{ ...metrics(9), id: 'gpt-example', label: 'gpt-example', bucket_start: '2026-09-05T00:00:00Z' }], providers: [{ ...metrics(9), id: 'provider-1', label: 'Provider 1' }],
|
||||
})
|
||||
const mounted: { app: App; root: HTMLElement; pinia: Pinia }[] = []
|
||||
async function settle() { for (let i = 0; i < 12; i++) { await Promise.resolve(); await nextTick() } await new Promise(resolve => setTimeout(resolve, 0)) }
|
||||
async function mount() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(AdminDashboard)
|
||||
const pinia = createPinia()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: AdminDashboard },
|
||||
{ path: '/admin/announcements', component: { render: () => null } },
|
||||
],
|
||||
})
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
useAnnouncementStore(pinia).resetSession('dashboard-admin')
|
||||
mounted.push({ app, root, pinia })
|
||||
await router.isReady()
|
||||
app.mount(root)
|
||||
await settle()
|
||||
return { root }
|
||||
}
|
||||
|
||||
function card(root: HTMLElement, label: string) {
|
||||
return Array.from(root.querySelectorAll('section')).find(section => Array.from(section.querySelectorAll('p')).some(p => p.textContent?.trim() === label))
|
||||
}
|
||||
|
||||
function primaryValue(root: HTMLElement, label: string) {
|
||||
return card(root, label)?.querySelector('p')?.nextElementSibling as HTMLElement | null | undefined
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })))
|
||||
setI18nLocale('zh-CN')
|
||||
vi.resetAllMocks()
|
||||
Object.assign(auth, { canAccessAdmin: true, isAdmin: true, isAuditAdmin: false })
|
||||
api.dashboardSummary.mockResolvedValue(dashboardSummary())
|
||||
announcementApi.getUserAnnouncements.mockResolvedValue({ items: [], total: 0, unread_count: 0 })
|
||||
legacyApi.getStats.mockResolvedValue({
|
||||
stats: [{ name: 'Legacy request metric', value: '999999', icon: 'Activity' }],
|
||||
system_health: { avg_response_time: 1.5, error_rate: 2, error_requests: 1, fallback_count: 3, total_requests: 50 },
|
||||
cost_stats: { total_cost: 42.5, total_actual_cost: 35, cost_savings: 7.5 },
|
||||
cache_stats: { cache_creation_tokens: 10, cache_read_tokens: 20, total_cache_tokens: 30 },
|
||||
})
|
||||
legacyApi.getDailyStats.mockResolvedValue({
|
||||
daily_stats: [{ date: '2026-09-05', requests: 777, tokens: 8888, cost: 3.5, avg_response_time: 1.2, unique_models: 1, unique_providers: 1, model_breakdown: [{ model: 'gpt-example', cost: 3.5 }] }],
|
||||
provider_summary: [{ provider: 'Primary', requests: 777, tokens: 8888, cost: 3.5 }], model_summary: [], period: { start_date: '2026-09-05', end_date: '2026-09-11', days: 7 },
|
||||
})
|
||||
})
|
||||
afterEach(() => { for (const { app, root, pinia } of mounted.splice(0)) { app.unmount(); disposePinia(pinia); root.remove() } vi.unstubAllGlobals() })
|
||||
|
||||
function metric(root: HTMLElement, key: string) {
|
||||
return root.querySelector(`[data-request-metric="${key}"]`)
|
||||
}
|
||||
|
||||
function metricValue(root: HTMLElement, key: string) {
|
||||
return metric(root, key)?.lastElementChild?.textContent?.trim()
|
||||
}
|
||||
|
||||
describe('administrator dashboard compact presentation', () => {
|
||||
it('shows five primary cards, six request metrics, announcements, and the activity streak', async () => {
|
||||
const { root } = await mount()
|
||||
expect(card(root, '今日请求')?.textContent).toContain('103')
|
||||
expect(card(root, '今日请求')?.textContent).toContain('总请求 12,345')
|
||||
const tokens = card(root, '今日 Token')
|
||||
expect(tokens?.textContent).toContain('12K / 3.46K')
|
||||
expect(tokens?.textContent).toContain('总 Token 1.23M')
|
||||
const cache = card(root, '今日缓存')
|
||||
expect(tokens?.nextElementSibling).toBe(cache)
|
||||
expect(cache?.textContent).toContain('30.00%')
|
||||
expect(cache?.textContent).toContain('总缓存 45.00%')
|
||||
expect(card(root, '今日消费')?.textContent).toContain('$9.87')
|
||||
expect(card(root, '今日消费')?.textContent).toContain('总消费 $98.76')
|
||||
const users = card(root, '今日活跃用户')
|
||||
expect(users?.textContent).toContain('总用户 14')
|
||||
expect(users?.textContent).toContain('+2')
|
||||
expect(users?.textContent).toContain('-1')
|
||||
expect(metric(root, 'first-byte')?.textContent).toContain('80ms')
|
||||
expect(metric(root, 'response')?.textContent).toContain('1.52s')
|
||||
expect(Array.from(root.querySelectorAll('[data-request-metric]'), item => item.getAttribute('data-request-metric')))
|
||||
.toEqual(['first-byte', 'response', 'concurrency-avg', 'concurrency-peak', 'stream', 'standard'])
|
||||
expect(metric(root, 'cache-hit')).toBeNull()
|
||||
expect(metric(root, 'cache-tokens')).toBeNull()
|
||||
expect(metric(root, 'concurrency-avg')?.textContent).toContain('2.5')
|
||||
expect(metric(root, 'concurrency-peak')?.textContent).toContain('8')
|
||||
expect(metric(root, 'stream')?.textContent).toContain('90')
|
||||
expect(metric(root, 'standard')?.textContent).toContain('13')
|
||||
const activity = root.querySelector('[data-dashboard-activity]')
|
||||
expect(activity?.textContent).toContain('连续活跃')
|
||||
expect(activity?.textContent).toContain('2 / 413')
|
||||
expect(root.textContent?.indexOf('连续活跃')).toBeLessThan(root.textContent?.indexOf('统计周期') ?? -1)
|
||||
expect(root.querySelector('[data-timeline]')?.textContent).toContain('最近24小时')
|
||||
expect(root.textContent).not.toContain('P95')
|
||||
expect(root.textContent).not.toContain('P99')
|
||||
expect(root.textContent).not.toContain('成功率')
|
||||
expect(root.textContent).not.toContain('RPM')
|
||||
expect(root.textContent).not.toContain('TPM')
|
||||
expect(root.textContent).not.toContain('统计自')
|
||||
expect(root.textContent).not.toContain('累计统计时间')
|
||||
expect(card(root, '今日请求')?.querySelector('svg')).toBeNull()
|
||||
expect(metric(root, 'first-byte')?.closest('section')?.querySelector('svg')).toBeNull()
|
||||
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
|
||||
expect(api.summary).not.toHaveBeenCalled()
|
||||
expect(api.dashboardTotal).not.toHaveBeenCalled()
|
||||
expect(api.dashboardCharts).not.toHaveBeenCalled()
|
||||
expect(legacyApi.getStats).not.toHaveBeenCalled()
|
||||
expect(announcementApi.getAnnouncements).not.toHaveBeenCalled()
|
||||
expect(announcementApi.getUserAnnouncements).toHaveBeenCalledExactlyOnceWith({ limit: 20, offset: 0 })
|
||||
expect(root.querySelector('#announcements-section')?.textContent).toContain('系统公告')
|
||||
expect(root.querySelector('#announcements-section a')?.getAttribute('href')).toBe('/admin/announcements')
|
||||
})
|
||||
|
||||
it('keeps today and all-period totals fixed when the chart period changes', async () => {
|
||||
const { root } = await mount()
|
||||
root.querySelector<HTMLButtonElement>('[data-period-control]')!.click()
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
await settle()
|
||||
expect(legacyApi.getDailyStats).toHaveBeenCalledTimes(2)
|
||||
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
|
||||
expect(card(root, '今日请求')?.textContent).toContain('12,345')
|
||||
})
|
||||
|
||||
it('uses the same snapshot for audit administrators', async () => {
|
||||
Object.assign(auth, { isAdmin: false, isAuditAdmin: true, canAccessAdmin: true })
|
||||
const { root } = await mount()
|
||||
expect(root.textContent).toContain('AUDIT MODE')
|
||||
expect(card(root, '今日请求')?.textContent).toContain('12,345')
|
||||
expect(api.dashboardSummary).toHaveBeenCalledTimes(1)
|
||||
expect(legacyApi.getStats).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps unavailable values unknown and does not invent cache hit rate from output tokens', async () => {
|
||||
const snapshot = dashboardSummary()
|
||||
Object.assign(snapshot.today, { input_tokens: null, output_tokens: null, cache_read_tokens: 0, cache_input_tokens: 0, avg_first_byte_ms: null, avg_response_ms: null, billable_amount: { value: null, status: 'unknown', currency: 'USD', basis: 'billable' } })
|
||||
Object.assign(snapshot.total, { cache_read_tokens: null, cache_input_tokens: null })
|
||||
snapshot.concurrency = { avg: null, peak: null, observed_from: null, observed_through: null, scope: 'node', coverage: 'unavailable' }
|
||||
api.dashboardSummary.mockResolvedValue(snapshot)
|
||||
const { root } = await mount()
|
||||
for (const key of ['first-byte', 'response', 'concurrency-avg', 'concurrency-peak']) {
|
||||
expect(metricValue(root, key)).toBe('—')
|
||||
}
|
||||
expect(primaryValue(root, '今日 Token')?.textContent?.trim()).toBe('— / —')
|
||||
expect(primaryValue(root, '今日消费')?.textContent?.trim()).toBe('—')
|
||||
expect(primaryValue(root, '今日缓存')?.textContent?.trim()).toBe('—')
|
||||
expect(card(root, '今日缓存')?.textContent).toContain('总缓存 —')
|
||||
expect(card(root, '今日缓存')?.textContent).not.toContain('0.00%')
|
||||
expect(root.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('puts collection boundaries, partial prices, and node observation coverage in tooltips only', async () => {
|
||||
const snapshot = dashboardSummary()
|
||||
snapshot.today.billable_amount.status = 'known_subtotal'
|
||||
api.dashboardSummary.mockResolvedValue(snapshot)
|
||||
const { root } = await mount()
|
||||
expect(card(root, '今日请求')?.querySelector('p')?.title).toContain('统计自')
|
||||
expect(primaryValue(root, '今日消费')?.querySelector('[title]')?.getAttribute('title')).toContain('部分请求价格未知')
|
||||
const concurrency = metric(root, 'concurrency-avg')?.closest('section')
|
||||
expect(concurrency?.title).toContain('当前节点')
|
||||
expect(concurrency?.title).toContain('部分时段')
|
||||
expect(concurrency?.title).toContain('02:00')
|
||||
expect(concurrency?.title).toContain('04:00')
|
||||
expect(root.textContent).not.toContain('已知小计')
|
||||
expect(root.textContent).not.toContain('部分时段')
|
||||
expect(root.textContent).not.toContain('统计自')
|
||||
const heatmapDay = root.querySelector<HTMLElement>('[title*="250"]')
|
||||
expect(heatmapDay?.title).not.toContain('$0')
|
||||
expect(heatmapDay?.title).not.toContain('tokens')
|
||||
})
|
||||
|
||||
it('leaves primary and request values blank on failure without error text or a retry button', async () => {
|
||||
api.dashboardSummary.mockRejectedValue(new Error('Unavailable'))
|
||||
const { root } = await mount()
|
||||
expect(root.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(root.textContent).not.toContain('加载失败')
|
||||
expect(root.textContent).not.toContain('重试')
|
||||
expect(card(root, '今日请求')?.textContent).toContain('—')
|
||||
for (const key of ['first-byte', 'response', 'concurrency-avg', 'concurrency-peak', 'stream', 'standard']) {
|
||||
expect(metricValue(root, key)).toBe('—')
|
||||
}
|
||||
expect(root.querySelector('[data-chart="bar"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dashboard chart accounting', () => {
|
||||
it('preserves the full provider sum when collapsing additional groups and excludes unknown amounts', () => {
|
||||
const rows = Array.from({ length: 105 }, (_, i) => ({ ...metrics(1), id: String(i), label: `Provider ${i}`, billable_amount: amount(String(i + 1)) }))
|
||||
rows.push({ ...metrics(1), id: 'unknown', label: 'Unknown cost', billable_amount: amount(null) })
|
||||
const slices = providerSlices(rows, 'Unknown provider', 'Others')
|
||||
expect(slices).toHaveLength(7)
|
||||
expect(slices.reduce((sum, slice) => sum + slice.value, 0)).toBe(5565)
|
||||
expect(slices.some(slice => slice.label === 'Unknown cost')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps unknown model amounts null and does not use period percentages to distribute daily amounts', () => {
|
||||
const data = charts()
|
||||
data.series.push({ ...metrics(1), bucket_start: '2026-09-06T00:00:00Z' })
|
||||
data.models = [
|
||||
{ ...metrics(1), id: 'gpt-example', label: 'gpt-example', bucket_start: '2026-09-05T00:00:00+00:00', billable_amount: amount('3') },
|
||||
{ ...metrics(1), id: 'gpt-example', label: 'gpt-example', bucket_start: data.series[1]!.bucket_start, billable_amount: amount(null) },
|
||||
]
|
||||
expect(modelDatasets(data, 'Unknown model', 'Others')[0]).toMatchObject({ label: 'gpt-example', data: [3, null] })
|
||||
})
|
||||
|
||||
it('uses a 23-hour local day during daylight saving and clips it to report boundaries', () => {
|
||||
const dstRange = { from: '2026-03-07T05:00:00Z', to: '2026-03-10T04:00:00Z', timezone: 'America/New_York' }
|
||||
expect(dayRange('2026-03-08T05:00:00Z', dstRange)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: dstRange.timezone })
|
||||
expect(dayRange('2026-03-08T05:00:00Z', { ...dstRange, to: '2026-03-08T18:00:00Z' }).to).toBe('2026-03-08T18:00:00.000Z')
|
||||
})
|
||||
|
||||
it('uses the first valid local minute when a timezone skips midnight', () => {
|
||||
const santiago = { from: '2026-09-05T04:00:00Z', to: '2026-09-08T03:00:00Z', timezone: 'America/Santiago' }
|
||||
expect(dayRange('2026-09-05T04:00:00Z', santiago).to).toBe('2026-09-06T04:00:00.000Z')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { OverviewDashboardSummary } from '@/api/overview'
|
||||
|
||||
export function dashboardSummary(): OverviewDashboardSummary {
|
||||
const amount = (value: string) => ({ value, currency: 'USD', basis: 'billable', status: 'known' })
|
||||
return {
|
||||
stats_since: '2026-09-18T00:00:00Z', generated_at: '2026-09-19T04:00:00Z', timezone: 'UTC',
|
||||
today_from: '2026-09-19T00:00:00Z', window_seconds: 14400,
|
||||
today: {
|
||||
request_count: 103, input_tokens: 12000, output_tokens: 3456, total_tokens: 15456,
|
||||
billable_amount: amount('9.87'), active_users: 8,
|
||||
cache_read_tokens: 6000, cache_creation_tokens: 1000, cache_input_tokens: 20000,
|
||||
avg_first_byte_ms: 80, avg_response_ms: 1520, stream_requests: 90, standard_requests: 13,
|
||||
},
|
||||
total: { request_count: 12345, total_tokens: 1234567, billable_amount: amount('98.76'), cache_read_tokens: 360000, cache_input_tokens: 800000 },
|
||||
users: { total: 14, created_today: 2, deleted_today: 1 },
|
||||
consecutive_active_days: 2, active_days: 413,
|
||||
activity_days: [{ date: '2026-09-18', requests: 250 }, { date: '2026-09-19', requests: 103 }],
|
||||
concurrency: { avg: 2.5, peak: 8, observed_from: '2026-09-19T02:00:00Z', observed_through: '2026-09-19T04:00:00Z', scope: 'node', coverage: 'partial' },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { createApp, h, nextTick, ref, type App } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PerformanceView from '../operations/PerformanceView.vue'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
import type { OverviewExecutionActivity } from '@/api/overview'
|
||||
|
||||
const api = vi.hoisted(() => ({ performance: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('../components/OverviewTrend.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('../components/OverviewStatus.vue', () => ({ default: { render: () => null } }))
|
||||
|
||||
let app: App | undefined
|
||||
beforeEach(() => { vi.clearAllMocks(); setI18nLocale('zh-CN') })
|
||||
afterEach(() => { app?.unmount(); app = undefined })
|
||||
|
||||
async function settle() {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
async function mount(url = '/admin/operations?timezone=UTC', initialActivity?: OverviewExecutionActivity | null) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/admin/operations', component: PerformanceView },
|
||||
{ path: '/admin/usage', component: { render: () => null } },
|
||||
] })
|
||||
await router.push(url)
|
||||
const root = document.createElement('div')
|
||||
const activity = ref(initialActivity)
|
||||
app = createApp({ render: () => h(PerformanceView, { revision: 0, activity: activity.value }) }).use(router)
|
||||
app.mount(root)
|
||||
await settle()
|
||||
return { root, router, activity }
|
||||
}
|
||||
function performance(rate: number | null, errors: number | null = 3, requests = 100) {
|
||||
return {
|
||||
request_count: requests, success_count: errors == null ? null : requests - errors,
|
||||
error_count: errors, success_rate: rate,
|
||||
avg_first_byte_time_ms: null as number | null, avg_output_tps: null as number | null,
|
||||
avg_response_time_ms: null, slow_request_count: 3,
|
||||
}
|
||||
}
|
||||
function provider(id: string, rate: number | null, errors: number | null = 3, requests = 100) {
|
||||
return { ...performance(rate, errors, requests), provider_id: id, provider: id }
|
||||
}
|
||||
function model(name: string | null, rate: number | null, errors: number | null = 3, requests = 100) {
|
||||
return { ...performance(rate, errors, requests), model: name }
|
||||
}
|
||||
function response(providers = [provider('example', 97)], models = [model('model-a', 95)]) {
|
||||
return { data: {
|
||||
summary: { request_count: 300, latency_ms: { p95: null } },
|
||||
timeseries: [], errors: [], models,
|
||||
providers: {
|
||||
summary: { avg_first_byte_time_ms: 0, avg_output_tps: null, slow_request_count: 3 },
|
||||
providers, timeline: [],
|
||||
},
|
||||
} }
|
||||
}
|
||||
function analysis(root: HTMLElement, dimension: 'provider' | 'model') {
|
||||
return root.querySelector<HTMLElement>(`[data-performance-analysis="${dimension}"]`)!
|
||||
}
|
||||
function rows(root: HTMLElement, dimension: 'provider' | 'model') {
|
||||
return Array.from(analysis(root, dimension).querySelectorAll<HTMLTableRowElement>('[data-analysis-row]'))
|
||||
}
|
||||
function liveActivity(): OverviewExecutionActivity {
|
||||
return {
|
||||
observed_at: '2026-09-20T08:00:00Z', observed_from: '2026-09-20T07:59:00Z',
|
||||
window_seconds: 60, observed_window_seconds: 60, scope: { kind: 'node' }, coverage: 'complete',
|
||||
providers: [{ provider_id: 'example', provider: 'Example', requests_per_minute: 83, current_concurrency: 7 }],
|
||||
models: [{ model: 'model-a', requests_per_minute: 61, current_concurrency: 4 }],
|
||||
}
|
||||
}
|
||||
function loadValues(root: HTMLElement, dimension: 'provider' | 'model') {
|
||||
return rows(root, dimension).map(row => ({
|
||||
entity: row.dataset.analysisRow,
|
||||
concurrency: row.querySelector('[data-analysis-concurrency]')?.textContent?.trim(),
|
||||
rpm: row.querySelector('[data-analysis-rpm]')?.textContent?.trim(),
|
||||
}))
|
||||
}
|
||||
function linkQuery(link: HTMLAnchorElement) {
|
||||
const url = new URL(link.getAttribute('href')!, 'http://localhost')
|
||||
expect(url.pathname).toBe('/admin/usage')
|
||||
return Object.fromEntries(url.searchParams)
|
||||
}
|
||||
|
||||
const fixedRange = {
|
||||
from: '2026-09-18T00:00:00.000Z', to: '2026-09-19T00:00:00.000Z', timezone: 'UTC',
|
||||
}
|
||||
const legacyUrl = '/admin/operations?from=2026-09-18T00:00:00Z&to=2026-09-19T00:00:00Z&timezone=UTC&model=old-model&api_format=openai&is_stream=false&has_format_conversion=true&provider_id=old-provider&slow_threshold_ms=7500&status=failed'
|
||||
|
||||
describe('operations automatic analysis', () => {
|
||||
it('shows provider and model reports together without filters and uses the backend model aggregates', async () => {
|
||||
api.performance.mockResolvedValue(response(
|
||||
[provider('provider-a', 97, 3, 100), provider('provider-b', 95, 10, 200)],
|
||||
[model('shared-model', 96.25, 6, 160)],
|
||||
))
|
||||
const { root } = await mount()
|
||||
expect(root.querySelectorAll('[data-performance-analysis]').length).toBe(2)
|
||||
expect(root.querySelector('form, input, select, [role="tab"], [role="tablist"]')).toBeNull()
|
||||
expect(rows(root, 'provider')).toHaveLength(2)
|
||||
const modelRows = rows(root, 'model')
|
||||
expect(modelRows).toHaveLength(1)
|
||||
expect(modelRows[0].querySelector('[data-analysis-entity-link]')?.textContent).toContain('shared-model')
|
||||
expect(modelRows[0].querySelectorAll('td')[1].textContent).toContain('160')
|
||||
expect(modelRows[0].querySelectorAll('td')[2].textContent).toContain('96.25%')
|
||||
expect(root.querySelector<HTMLDetailsElement>('[data-operations-diagnostics]')?.open).toBe(false)
|
||||
expect(api.performance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('orders measured failures first, uses percentage points and distinguishes unavailable values from zero', async () => {
|
||||
const measured = provider('measured', 97.25, 11)
|
||||
measured.avg_first_byte_time_ms = 0
|
||||
measured.avg_output_tps = 0
|
||||
api.performance.mockResolvedValue(response([
|
||||
provider('unavailable', null, null),
|
||||
provider('no-failures', 100, 0, 1_000),
|
||||
measured,
|
||||
provider('zero-success', 0, 25),
|
||||
], [model('model-zero', 0, 100), model('model-unknown', null, null)]))
|
||||
const { root } = await mount()
|
||||
const providerRows = rows(root, 'provider')
|
||||
expect(providerRows.map(row => row.dataset.analysisRow)).toEqual(['zero-success', 'measured', 'no-failures', 'unavailable'])
|
||||
expect(providerRows[0].querySelectorAll('td')[2].textContent).toContain('0%')
|
||||
expect(providerRows[1].querySelectorAll('td')[2].textContent).toContain('97.25%')
|
||||
expect(providerRows[3].querySelectorAll('td')[2].textContent?.trim()).toMatch(/^-/)
|
||||
expect(providerRows[1].querySelectorAll('td')[5].textContent?.trim()).toBe('0 ms')
|
||||
expect(providerRows[1].querySelectorAll('td')[6].textContent?.trim()).toBe('0')
|
||||
expect(providerRows[3].querySelectorAll('td')[5].textContent?.trim()).toBe('-')
|
||||
expect(providerRows[3].querySelectorAll('td')[6].textContent?.trim()).toBe('-')
|
||||
const modelRows = rows(root, 'model')
|
||||
expect(modelRows[0].querySelectorAll('td')[2].textContent).toContain('0%')
|
||||
expect(modelRows[1].querySelectorAll('td')[2].textContent?.trim()).toMatch(/^-/)
|
||||
})
|
||||
|
||||
it('shows current provider and model load independently of the selected historical range', async () => {
|
||||
api.performance.mockResolvedValue(response())
|
||||
const { root, router } = await mount(legacyUrl, liveActivity())
|
||||
expect(loadValues(root, 'provider')).toEqual([{ entity: 'example', concurrency: '7', rpm: '83' }])
|
||||
expect(loadValues(root, 'model')).toEqual([{ entity: 'model-a', concurrency: '4', rpm: '61' }])
|
||||
expect(analysis(root, 'provider').querySelector('thead')?.textContent).toContain('当前并发')
|
||||
expect(analysis(root, 'provider').querySelector('thead')?.textContent).toContain('近 60 秒')
|
||||
|
||||
api.performance.mockResolvedValue(response([provider('example', 99, 10, 1_000)], [model('model-a', 99, 10, 1_000)]))
|
||||
await router.replace({ query: { from: '2026-09-01T00:00:00Z', to: '2026-09-19T00:00:00Z', timezone: 'UTC' } })
|
||||
await settle()
|
||||
expect(api.performance).toHaveBeenCalledTimes(2)
|
||||
expect(rows(root, 'provider')[0].querySelectorAll('td')[1].textContent).toContain('1,000')
|
||||
expect(loadValues(root, 'provider')).toEqual([{ entity: 'example', concurrency: '7', rpm: '83' }])
|
||||
expect(loadValues(root, 'model')).toEqual([{ entity: 'model-a', concurrency: '4', rpm: '61' }])
|
||||
})
|
||||
|
||||
it('distinguishes measured zero load from missing or incomplete live coverage', async () => {
|
||||
api.performance.mockResolvedValue(response())
|
||||
const { root, activity } = await mount()
|
||||
for (const dimension of ['provider', 'model'] as const) {
|
||||
expect(loadValues(root, dimension)[0]).toMatchObject({ concurrency: '-', rpm: '-' })
|
||||
}
|
||||
activity.value = { ...liveActivity(), providers: [], models: [] }
|
||||
await settle()
|
||||
for (const dimension of ['provider', 'model'] as const) {
|
||||
expect(loadValues(root, dimension)[0]).toMatchObject({ concurrency: '0', rpm: '0' })
|
||||
}
|
||||
activity.value = { ...liveActivity(), coverage: 'partial' }
|
||||
await settle()
|
||||
for (const dimension of ['provider', 'model'] as const) {
|
||||
expect(loadValues(root, dimension)[0]).toMatchObject({ concurrency: '-', rpm: '-' })
|
||||
}
|
||||
activity.value = null
|
||||
await settle()
|
||||
expect(loadValues(root, 'provider')[0]).toMatchObject({ concurrency: '-', rpm: '-' })
|
||||
expect(api.performance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps new active providers and models visible before historical usage has been recorded', async () => {
|
||||
api.performance.mockResolvedValue(response([], []))
|
||||
const { root } = await mount(legacyUrl, liveActivity())
|
||||
expect(loadValues(root, 'provider')).toEqual([{ entity: 'example', concurrency: '7', rpm: '83' }])
|
||||
expect(loadValues(root, 'model')).toEqual([{ entity: 'model-a', concurrency: '4', rpm: '61' }])
|
||||
for (const dimension of ['provider', 'model'] as const) {
|
||||
const row = rows(root, dimension)[0]
|
||||
expect(row.querySelectorAll('td')[1].textContent?.trim()).toMatch(/^0/)
|
||||
expect(row.querySelector('[data-analysis-success-rate]')?.textContent).toBe('-')
|
||||
expect(row.querySelectorAll('td')[5].textContent?.trim()).toBe('-')
|
||||
expect(row.querySelectorAll('td')[6].textContent?.trim()).toBe('-')
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores legacy filters in requests and limits drilldowns to the selected range and analysis entity', async () => {
|
||||
api.performance.mockResolvedValue(response())
|
||||
const { root, router } = await mount(legacyUrl)
|
||||
expect(api.performance.mock.lastCall?.[0]).toMatchObject(fixedRange)
|
||||
for (const key of ['model', 'api_format', 'is_stream', 'has_format_conversion', 'provider_id', 'slow_threshold_ms', 'status']) {
|
||||
expect(api.performance.mock.lastCall?.[0][key]).toBeUndefined()
|
||||
}
|
||||
for (const [dimension, entity, key] of [['provider', 'example', 'provider_id'], ['model', 'model-a', 'model']] as const) {
|
||||
const card = analysis(root, dimension)
|
||||
const entityLink = card.querySelector<HTMLAnchorElement>(`[data-analysis-entity-link="${entity}"]`)!
|
||||
const failureLink = card.querySelector<HTMLAnchorElement>(`[data-analysis-failure-link="${entity}"]`)!
|
||||
expect(linkQuery(entityLink)).toEqual({ ...fixedRange, [key]: entity })
|
||||
expect(linkQuery(failureLink)).toEqual({ ...fixedRange, [key]: entity, status: 'failed' })
|
||||
}
|
||||
await router.replace({ query: { ...router.currentRoute.value.query, model: 'another-old-model', provider_id: 'another-old-provider', slow_threshold_ms: '15000' } })
|
||||
await settle()
|
||||
expect(api.performance).toHaveBeenCalledTimes(1)
|
||||
expect(rows(root, 'model')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([null, ''])('does not turn an unattributed model (%s) into an unrestricted request link', async missingModel => {
|
||||
api.performance.mockResolvedValue(response([], [model(missingModel, 100, 0)]))
|
||||
const { root } = await mount(legacyUrl)
|
||||
expect(rows(root, 'provider')).toHaveLength(0)
|
||||
const modelRows = rows(root, 'model')
|
||||
expect(modelRows).toHaveLength(1)
|
||||
expect(modelRows[0].querySelector('a')).toBeNull()
|
||||
expect(modelRows[0].querySelectorAll('td')[1].textContent).toContain('100')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presetRange, rangeFromQuery, zonedInput, zonedInstant } from '../query'
|
||||
|
||||
describe('overview time range contract', () => {
|
||||
it('keeps a rolling hour precise across midnight', () => {
|
||||
expect(presetRange('last1hour', 'Asia/Shanghai', new Date('2026-09-10T16:20:42Z'))).toEqual({
|
||||
from: '2026-09-10T15:20:42.000Z', to: '2026-09-10T16:20:42.000Z', timezone: 'Asia/Shanghai',
|
||||
})
|
||||
})
|
||||
it('uses the report timezone for today rather than the browser calendar', () => {
|
||||
expect(presetRange('today', 'America/New_York', new Date('2026-03-08T16:00:00Z')).from).toBe('2026-03-08T05:00:00.000Z')
|
||||
})
|
||||
it('rejects a skipped DST time and picks the earlier repeated time', () => {
|
||||
expect(zonedInstant('2026-03-08T02:30', 'America/New_York')).toBeNull()
|
||||
expect(zonedInstant('2026-11-01T01:30', 'America/New_York')).toBe('2026-11-01T05:30:00.000Z')
|
||||
expect(zonedInput('2026-11-01T05:30:00Z', 'America/New_York')).toBe('2026-11-01T01:30')
|
||||
})
|
||||
it('normalizes offset timestamps and preserves the half-open boundary', () => {
|
||||
const fallback = presetRange('last7days')
|
||||
expect(rangeFromQuery({ from: '2026-09-11T00:00:00+08:00', to: '2026-09-11T00:01:00+08:00', timezone: 'Asia/Shanghai' }, fallback)).toEqual({
|
||||
from: '2026-09-10T16:00:00.000Z', to: '2026-09-10T16:01:00.000Z', timezone: 'Asia/Shanghai',
|
||||
})
|
||||
expect(rangeFromQuery({ from: 'invalid', to: 'invalid' }, fallback)).toBe(fallback)
|
||||
})
|
||||
it('migrates legacy calendar dates across DST and yesterday presets', () => {
|
||||
const fallback = { from: '2026-03-01T00:00:00Z', to: '2026-03-09T16:00:00Z', timezone: 'America/New_York' }
|
||||
expect(rangeFromQuery({ start_date: '2026-03-08', end_date: '2026-03-08', timezone: 'America/New_York' }, fallback)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: 'America/New_York' })
|
||||
expect(rangeFromQuery({ preset: 'yesterday' }, fallback)).toEqual({ from: '2026-03-08T05:00:00.000Z', to: '2026-03-09T04:00:00.000Z', timezone: 'America/New_York' })
|
||||
expect(rangeFromQuery({ preset: 'last7days' }, fallback)).toEqual({ from: '2026-03-03T05:00:00.000Z', to: '2026-03-10T04:00:00.000Z', timezone: 'America/New_York' })
|
||||
})
|
||||
it('aligns generated presets to seconds for request-detail compatibility', () => {
|
||||
const range = presetRange('last1hour', 'UTC', new Date('2026-09-11T10:25:06.987Z'))
|
||||
expect(range.from).toBe('2026-09-11T09:25:06.000Z')
|
||||
expect(range.to).toBe('2026-09-11T10:25:06.000Z')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
|
||||
describe('overview request lifecycle', () => {
|
||||
it('aborts replaced requests and never publishes a stale result', async () => {
|
||||
const source = ref('first')
|
||||
const requests: { signal: AbortSignal; resolve: (value: string) => void }[] = []
|
||||
const fetcher = vi.fn((signal: AbortSignal) => new Promise<string>(resolve => requests.push({ signal, resolve })))
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useOverviewRequest(source, fetcher))!
|
||||
source.value = 'second'
|
||||
await nextTick()
|
||||
expect(requests[0]?.signal.aborted).toBe(true)
|
||||
requests[1]!.resolve('second result')
|
||||
await Promise.resolve()
|
||||
requests[0]!.resolve('first result')
|
||||
await Promise.resolve()
|
||||
expect(state.data.value).toBe('second result')
|
||||
scope.stop()
|
||||
expect(requests[1]?.signal.aborted).toBe(true)
|
||||
})
|
||||
it('retains the prior snapshot on failed manual refresh and exposes the error', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce('snapshot').mockRejectedValueOnce(new Error('database timeout'))
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useOverviewRequest(ref(1), fetcher))!
|
||||
await Promise.resolve()
|
||||
await state.refresh()
|
||||
expect(state.data.value).toBe('snapshot')
|
||||
expect(state.error.value).toBe('database timeout')
|
||||
scope.stop()
|
||||
})
|
||||
it('retains revision refresh snapshots but clears data when the filter scope changes', async () => {
|
||||
const revision = ref(0)
|
||||
const filter = ref('provider-a')
|
||||
const fetcher = vi.fn().mockResolvedValueOnce('provider-a snapshot').mockRejectedValue(new Error('database timeout'))
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => useOverviewRequest(() => [filter.value, revision.value], fetcher, { scopeKey: filter }))!
|
||||
await Promise.resolve()
|
||||
revision.value += 1
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(state.data.value).toBe('provider-a snapshot')
|
||||
expect(state.error.value).toBe('database timeout')
|
||||
filter.value = 'provider-b'
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(state.data.value).toBeNull()
|
||||
expect(state.error.value).toBe('database timeout')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createApp, h, nextTick, ref, type App, type VNode } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OverviewMetrics } from '@/api/overview'
|
||||
import ConsumptionTable from '../users/ConsumptionTable.vue'
|
||||
import PeriodComparison from '../dashboard/PeriodComparison.vue'
|
||||
import HealthSummary from '../dashboard/HealthSummary.vue'
|
||||
|
||||
const api = vi.hoisted(() => ({ consumption: vi.fn(), summary: vi.fn(), health: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('@/api/endpoints/health-v2', () => ({ getAdminHealthSummary: api.health }))
|
||||
|
||||
const amount = { value: '10', currency: 'USD', basis: 'billable', status: 'known' }
|
||||
const metrics = (requests: number): OverviewMetrics => ({
|
||||
request_count: requests, successful_request_count: requests, failed_request_count: 0,
|
||||
cancelled_request_count: 0, in_flight_request_count: 0, unclassified_failure_count: 0,
|
||||
input_tokens: requests, output_tokens: requests, total_tokens: requests * 2, usage_active_users: 1,
|
||||
success_rate: { value: 1, numerator: requests, denominator: requests },
|
||||
latency_ms: { avg: 100, p50: 100, p95: 100, p99: 100, sample_count: requests },
|
||||
rated_amount: amount, billable_amount: amount, quota_covered_amount: amount, wallet_consumed_amount: amount, wallet_debit_amount: amount,
|
||||
})
|
||||
const range = { from: '2026-09-10T00:00:00.000Z', to: '2026-09-11T00:00:00.000Z', timezone: 'UTC' }
|
||||
const meta = {
|
||||
range, generated_at: '2026-09-11T00:00:00Z', data_through: null,
|
||||
coverage: { status: 'complete', request_count: 10, usage_available_count: 10, pricing_available_count: 10, settled_count: 10 },
|
||||
}
|
||||
const mounted: App[] = []
|
||||
async function settle() { await Promise.resolve(); await nextTick(); await new Promise(resolve => setTimeout(resolve, 0)) }
|
||||
async function mount(render: () => VNode) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/admin/user-stats/:userId', component: { render: () => null } },
|
||||
{ path: '/admin/usage', component: { render: () => null } },
|
||||
{ path: '/admin/health-monitor', component: { render: () => null } },
|
||||
] })
|
||||
await router.push({ path: '/admin/user-stats/member-a', query: range })
|
||||
const root = document.createElement('div')
|
||||
const app = createApp({ render }).use(router)
|
||||
app.mount(root)
|
||||
mounted.push(app)
|
||||
await settle()
|
||||
return root
|
||||
}
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
afterEach(() => { for (const app of mounted.splice(0)) app.unmount() })
|
||||
|
||||
describe('revision-driven overview snapshots', () => {
|
||||
it('retains consumption while refreshing or after failure but clears another member’s data', async () => {
|
||||
api.consumption.mockResolvedValue({ meta, data: { total: 1, limit: 25, offset: 0, items: [{
|
||||
id: 'usage-a', request_id: 'request-a', started_at: range.from, user_id: 'member-a', model: 'model-a',
|
||||
provider: 'provider-a', status: 'success', settlement_status: 'settled', attribution_kind: 'employee',
|
||||
rated_amount: amount, billable_amount: amount, quota_covered_amount: amount, wallet_consumed_amount: amount, wallet_debit_amount: amount,
|
||||
}] } })
|
||||
const revision = ref(0)
|
||||
const userId = ref('member-a')
|
||||
const root = await mount(() => h(ConsumptionTable, { userId: userId.value, revision: revision.value }))
|
||||
expect(root.textContent).toContain('request-a')
|
||||
let rejectRefresh!: (reason: Error) => void
|
||||
api.consumption.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectRefresh = reject }))
|
||||
revision.value += 1
|
||||
await nextTick()
|
||||
expect(root.textContent).toContain('request-a')
|
||||
rejectRefresh(new Error('consumption unavailable'))
|
||||
await settle()
|
||||
expect(root.textContent).toContain('request-a')
|
||||
expect(root.querySelector('[role="alert"]')?.textContent).toContain('consumption unavailable')
|
||||
api.consumption.mockRejectedValue(new Error('member lookup unavailable'))
|
||||
userId.value = 'member-b'
|
||||
await settle()
|
||||
expect(root.textContent).not.toContain('request-a')
|
||||
expect(api.consumption.mock.lastCall?.[0].user_id).toBe('member-b')
|
||||
})
|
||||
|
||||
it('retains comparison values after failed refresh but clears them for a changed period', async () => {
|
||||
api.summary.mockResolvedValue({ meta, data: metrics(10) })
|
||||
const revision = ref(0)
|
||||
const selectedRange = ref(range)
|
||||
const root = await mount(() => h(PeriodComparison, { current: metrics(20), range: selectedRange.value, revision: revision.value }))
|
||||
expect(root.querySelector('dl')?.textContent).toContain('+100.0%')
|
||||
api.summary.mockRejectedValue(new Error('summary unavailable'))
|
||||
revision.value += 1
|
||||
await settle()
|
||||
expect(root.querySelector('dl')?.textContent).toContain('+100.0%')
|
||||
expect(root.querySelector('[role="alert"]')).not.toBeNull()
|
||||
selectedRange.value = { ...range, from: '2026-09-09T00:00:00.000Z' }
|
||||
await settle()
|
||||
expect(root.querySelector('dl')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps health metrics visible beside a failed revision refresh', async () => {
|
||||
api.health.mockResolvedValue({ meta: { generated_at: meta.generated_at, freshness: 'current' }, data: {
|
||||
status: 'healthy', requests: { service_availability: { value: 1 } },
|
||||
degraded_count: 0, unavailable_count: 0, unknown_count: 0, object_count: 2,
|
||||
} })
|
||||
const revision = ref(0)
|
||||
const root = await mount(() => h(HealthSummary, { revision: revision.value }))
|
||||
expect(root.querySelector('dl')?.textContent).toContain('100.00%')
|
||||
api.health.mockRejectedValue(new Error('health unavailable'))
|
||||
revision.value += 1
|
||||
await settle()
|
||||
expect(root.querySelector('dl')?.textContent).toContain('100.00%')
|
||||
expect(root.querySelector('[role="alert"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import RuntimeFocus from '../operations/RuntimeFocus.vue'
|
||||
import { buildGatewayMetricsSummary } from '@/api/monitoring'
|
||||
import type { OverviewLive } from '@/api/overview'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
let app: App | undefined
|
||||
afterEach(() => app?.unmount())
|
||||
|
||||
function snapshot(): OverviewLive {
|
||||
return {
|
||||
observed_at: '2026-09-20T00:00:00Z', window_seconds: 60, node_id: 'test-node',
|
||||
scope: { kind: 'node' }, metrics: buildGatewayMetricsSummary(''), resilience: null,
|
||||
unavailable_sections: [],
|
||||
}
|
||||
}
|
||||
|
||||
async function mount(value: OverviewLive) {
|
||||
setI18nLocale('zh-CN')
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: { render: () => null } }] })
|
||||
await router.push('/admin/operations')
|
||||
await router.isReady()
|
||||
const root = document.createElement('div')
|
||||
app = createApp(RuntimeFocus, { snapshot: value })
|
||||
app.use(router)
|
||||
app.mount(root)
|
||||
await nextTick()
|
||||
return root
|
||||
}
|
||||
|
||||
function valueOf(root: HTMLElement, key: string) {
|
||||
return root.querySelector(`[data-runtime-focus-item="${key}"] dd`)?.textContent
|
||||
}
|
||||
|
||||
describe('operations processing pipeline', () => {
|
||||
it('keeps unavailable samples distinct from measured zeroes', async () => {
|
||||
const sample = snapshot()
|
||||
sample.metrics!.requestCandidateQueue.depth = 0
|
||||
sample.metrics!.postgres.waitingConnections = 0
|
||||
const root = await mount(sample)
|
||||
expect(root.querySelectorAll('[data-runtime-focus-group]')).toHaveLength(3)
|
||||
expect(valueOf(root, 'candidate-queue')).toBe('0')
|
||||
expect(valueOf(root, 'database-waiting')).toBe('0')
|
||||
expect(valueOf(root, 'lock-waiting')).toBe('-')
|
||||
expect(valueOf(root, 'open-circuits')).toBe('-')
|
||||
expect(valueOf(root, 'usage-lag')).toBe('-')
|
||||
expect(root.querySelector('[data-runtime-focus-queue-state]')?.textContent).toBe('未采集')
|
||||
expect(root.textContent).not.toContain('正常')
|
||||
})
|
||||
|
||||
it('separates undelivered, unacknowledged and dead-letter entries, preserving a real zero', async () => {
|
||||
const sample = snapshot()
|
||||
Object.assign(sample.metrics!.usageQueue, { enabled: true, configured: true, unavailable: false, groupLag: 7, groupPending: 0, dlqLength: 3 })
|
||||
sample.resilience = {
|
||||
timestamp: '2026-09-20T00:00:00Z', health_score: 75, status: 'degraded', recent_errors: [], recommendations: [],
|
||||
error_statistics: { total_errors: 9, active_keys: 4, degraded_keys: 1, unhealthy_keys: 0, open_circuit_breakers: 2, circuit_breakers: {} },
|
||||
}
|
||||
const root = await mount(sample)
|
||||
const usage = root.querySelector('[data-runtime-focus-group="usage"]')!
|
||||
expect([...usage.querySelectorAll('dt')].map(item => item.textContent)).toEqual(['待消费', '待确认', '死信'])
|
||||
expect([...usage.querySelectorAll('dd')].map(item => item.textContent)).toEqual(['7', '0', '3'])
|
||||
expect(usage.querySelector('[data-runtime-focus-queue-state]')).toBeNull()
|
||||
expect(valueOf(root, 'open-circuits')).toBe('2')
|
||||
expect(valueOf(root, 'degraded-credentials')).toBe('1')
|
||||
expect(root.querySelector('a')?.getAttribute('href')).toContain('/admin/health-monitor')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ enabled: false }, '未启用'],
|
||||
[{ configured: false }, '未配置'],
|
||||
[{ unavailable: true }, '未采集'],
|
||||
])('does not present queue defaults as measurements when %s', async (flags, label) => {
|
||||
const sample = snapshot()
|
||||
Object.assign(sample.metrics!.usageQueue, { enabled: true, configured: true, unavailable: false, groupLag: 0, groupPending: 0, dlqLength: 0 }, flags)
|
||||
const root = await mount(sample)
|
||||
expect(root.querySelector('[data-runtime-focus-queue-state]')?.textContent).toBe(label)
|
||||
for (const key of ['usage-lag', 'usage-pending', 'usage-dead-letters']) expect(valueOf(root, key)).toBe('-')
|
||||
})
|
||||
|
||||
it('uses the supplied Prometheus snapshot when structured metrics are absent', async () => {
|
||||
const sample = snapshot()
|
||||
sample.metrics = null
|
||||
sample.metrics_text = 'usage_queue_group_lag 4\nusage_queue_group_pending 2\nusage_queue_dlq_length 0\n'
|
||||
const root = await mount(sample)
|
||||
expect(valueOf(root, 'usage-lag')).toBe('4')
|
||||
expect(valueOf(root, 'usage-pending')).toBe('2')
|
||||
expect(valueOf(root, 'usage-dead-letters')).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import RuntimeView from '../operations/RuntimeView.vue'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
const api = vi.hoisted(() => ({ live: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('../components/OverviewStatus.vue', () => ({ default: { render: () => null } }))
|
||||
|
||||
let app: App | undefined
|
||||
afterEach(() => { app?.unmount(); vi.resetAllMocks() })
|
||||
async function mount() {
|
||||
setI18nLocale('zh-CN')
|
||||
const root = document.createElement('div')
|
||||
app = createApp(RuntimeView, { revision: 0 })
|
||||
app.mount(root)
|
||||
for (let index = 0; index < 5; index += 1) { await Promise.resolve(); await nextTick() }
|
||||
return root
|
||||
}
|
||||
function metric(root: HTMLElement, label: string) { return root.querySelector(`[data-operations-metric="${label}"]`) }
|
||||
|
||||
describe('operations live throughput', () => {
|
||||
it('shows recent throughput and current-node concurrency from a single live snapshot', async () => {
|
||||
api.live.mockResolvedValue({ data: {
|
||||
metrics: {
|
||||
local: { inFlight: 8 }, tunnel: { activeStreams: 3 },
|
||||
process: {
|
||||
systemCpuUsageBasisPoints: 2640, systemMemoryUsageBasisPoints: 3750,
|
||||
processCpuUsageBasisPoints: 25000, processMemoryBytes: 512 * 1024 ** 2,
|
||||
systemMemoryUsedBytes: 6 * 1024 ** 3, systemMemoryTotalBytes: 16 * 1024 ** 3,
|
||||
},
|
||||
},
|
||||
recent_activity: { meta: {}, data: {
|
||||
requests_per_minute: 240, tokens_per_minute: 12000,
|
||||
requests_per_second: 4, request_count: 240,
|
||||
success_rate: { value: 0.95 }, failed_request_count: 12,
|
||||
} },
|
||||
} })
|
||||
const root = await mount()
|
||||
expect(root.querySelectorAll('[data-operations-hardware] [data-operations-metric]')).toHaveLength(4)
|
||||
expect(root.querySelectorAll('[data-operations-gateway] [data-operations-metric]')).toHaveLength(6)
|
||||
expect(metric(root, '主机 CPU')?.textContent).toContain('26.4%')
|
||||
expect(metric(root, '主机内存')?.textContent).toContain('37.5%')
|
||||
expect(metric(root, '主机内存')?.textContent).toContain('6 GiB / 16 GiB')
|
||||
expect(metric(root, '进程 CPU')?.textContent).toContain('250%')
|
||||
expect(metric(root, '进程内存')?.textContent).toContain('512 MiB')
|
||||
expect(metric(root, 'RPM')?.textContent).toContain('240')
|
||||
expect(metric(root, 'TPM')?.textContent).toContain('12,000')
|
||||
expect(metric(root, '请求成功率')?.textContent).toContain('95.00%')
|
||||
expect(metric(root, '失败请求')?.textContent).toContain('12')
|
||||
expect(metric(root, '当前并发')?.textContent).toContain('8')
|
||||
expect(metric(root, '活跃流')?.textContent).toContain('3')
|
||||
expect(metric(root, 'RPM')?.textContent).toContain('最近 60 秒')
|
||||
expect(metric(root, '当前并发')?.textContent).toContain('当前节点')
|
||||
expect(api.live).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps unavailable values distinct from measured zeroes', async () => {
|
||||
api.live.mockResolvedValue({ data: {
|
||||
metrics: { process: { systemCpuUsageBasisPoints: 0, systemMemoryTotalBytes: 0, systemMemoryUsageBasisPoints: 0 }, local: {}, tunnel: {} },
|
||||
recent_activity: { data: { requests_per_minute: 0, tokens_per_minute: null, success_rate: { value: null }, failed_request_count: 0 } },
|
||||
} })
|
||||
const root = await mount()
|
||||
expect(metric(root, 'RPM')?.querySelector('span[title]')?.textContent).toBe('0')
|
||||
expect(metric(root, 'TPM')?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
expect(metric(root, '请求成功率')?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
expect(metric(root, '当前并发')?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
expect(metric(root, '活跃流')?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
expect(metric(root, '主机 CPU')?.querySelector('span[title]')?.textContent).toBe('0%')
|
||||
for (const label of ['主机内存', '进程 CPU', '进程内存']) {
|
||||
expect(metric(root, label)?.querySelector('span[title]')?.textContent).toBe('-')
|
||||
}
|
||||
expect(api.live).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createApp, nextTick, type Component } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import UserStats from '@/views/admin/UserStats.vue'
|
||||
import UserAnalysisDetail from '@/views/admin/UserAnalysisDetail.vue'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
const api = vi.hoisted(() => ({ users: vi.fn(), user: vi.fn(), timeseries: vi.fn(), breakdown: vi.fn(), consumption: vi.fn(), exportCsv: vi.fn() }))
|
||||
const accountApi = vi.hoisted(() => ({ wallets: vi.fn(), transactions: vi.fn(), plans: vi.fn() }))
|
||||
vi.mock('@/api/overview', () => ({ overviewApi: api }))
|
||||
vi.mock('@/api/admin-wallets', () => ({ adminWalletApi: { listWallets: accountApi.wallets, getWalletTransactions: accountApi.transactions } }))
|
||||
vi.mock('@/api/users', () => ({ usersApi: { listUserPlanEntitlements: accountApi.plans } }))
|
||||
vi.mock('@/components/charts/BarChart.vue', () => ({ default: { render: () => null } }))
|
||||
vi.mock('@/components/charts/LineChart.vue', () => ({ default: { render: () => null } }))
|
||||
const range = 'from=2026-09-01T00:00:00Z&to=2026-09-02T00:00:00Z&timezone=UTC'
|
||||
const meta = {
|
||||
schema_version: 1, metric_version: 'overview-v2', scope: { kind: 'installation' },
|
||||
range: { from: '2026-09-01T00:00:00Z', to: '2026-09-02T00:00:00Z', timezone: 'UTC', time_basis: 'request_started_at' },
|
||||
generated_at: '2026-09-02T00:01:00Z', data_through: '2026-09-02T00:00:00Z', read_revision: 'test-1',
|
||||
coverage: { status: 'complete', request_count: 1, usage_available_count: 1, pricing_available_count: 1, settled_count: 1, attribution_available_count: 1 },
|
||||
}
|
||||
const amount = (value: string | null) => ({ value, currency: 'USD', basis: 'billable', status: value == null ? 'unknown' : 'known' })
|
||||
const metrics = { request_count: 120, successful_request_count: 118, failed_request_count: 2, total_tokens: 8800, billable_amount: amount('432.25'), quota_covered_amount: amount('300'), wallet_debit_amount: amount('132.25') }
|
||||
const finance = { wallet_balance: amount('765.50'), recharge_balance: amount('700'), gift_balance: amount('65.5'), recharge_amount: amount('1000'), recharge_count: 4, plan_purchase_amount: amount('99'), plan_purchase_count: 1, gift_credit_amount: amount('20'), gift_credit_count: 1, balance_time_basis: 'current', payment_time_basis: 'credited_at' }
|
||||
const employee = { user_id: 'employee-0', username: 'Zero Usage Employee', email: '[email protected]', is_active: true, last_used_at: null, active_days: 0, request_count: 0, total_tokens: 0, billable_amount: amount('0'), finance }
|
||||
const wallet = { id: 'wallet-0', user_id: employee.user_id, owner_type: 'user', balance: 765.5, recharge_balance: 700, gift_balance: 65.5, currency: 'USD' }
|
||||
const transaction = { id: 'tx-0', category: 'adjust', reason_code: 'adjust_admin', amount: -10, balance_before: 775.5, balance_after: 765.5, recharge_balance_before: 710, recharge_balance_after: 700, gift_balance_before: 65.5, gift_balance_after: 65.5, description: 'balance correction', created_at: '2026-08-01T08:00:00Z' }
|
||||
const plan = { id: 'plan-history-0', user_id: employee.user_id, plan_id: 'plan-0', plan_title: 'Old monthly plan', payment_order_id: 'order-0', status: 'revoked', active: false, starts_at: '2026-07-01T00:00:00Z', expires_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-01T00:00:00Z', entitlements: [{ type: 'daily_quota', daily_quota_usd: 20 }] }
|
||||
const cleanup: (() => void)[] = []
|
||||
async function settle() { await Promise.resolve(); await Promise.resolve(); await nextTick(); await new Promise(resolve => setTimeout(resolve, 0)) }
|
||||
async function mount(component: Component, path: string) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/admin/user-stats', component: UserStats }, { path: '/admin/user-stats/:userId', component: UserAnalysisDetail }, { path: '/admin/usage', component: { render: () => null } }] })
|
||||
await router.push(path)
|
||||
const root = document.createElement('div')
|
||||
const app = createApp(component).use(router)
|
||||
app.mount(root)
|
||||
cleanup.push(() => app.unmount())
|
||||
await settle()
|
||||
return { root, router }
|
||||
}
|
||||
function button(root: Element, label: string) {
|
||||
const result = [...root.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.includes(label) || item.getAttribute('aria-label')?.includes(label))
|
||||
if (!result) throw new Error(`Missing button: ${label}`)
|
||||
return result
|
||||
}
|
||||
function section(root: Element, selector: string) {
|
||||
const result = root.querySelector(selector)
|
||||
if (!result) throw new Error(`Missing section: ${selector}`)
|
||||
return result
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setI18nLocale('zh-CN')
|
||||
api.users.mockImplementation(async query => ({ meta, data: { total: 62, limit: query.limit, offset: query.offset, items: [employee], summary: { ...metrics, user_count: 62, active_user_count: 7 }, finance_summary: finance } }))
|
||||
api.user.mockImplementation(async (id, query) => ({ meta, data: { user: { id, username: id, email: '[email protected]', is_active: true }, summary: metrics, finance, payments: { items: [{ id: 'payment-1', order_no: 'credited-order', kind: 'wallet_recharge', amount: amount('1000'), payment_method: 'alipay', credited_at: '2026-09-01T01:00:00Z' }], total: 80, limit: query.payment_limit, offset: query.payment_offset } } }))
|
||||
api.timeseries.mockResolvedValue({ meta, data: { items: [], granularity: 'hour' } })
|
||||
api.breakdown.mockResolvedValue({ meta, data: { items: [], total: 0, limit: 10, offset: 0 } })
|
||||
api.consumption.mockImplementation(async query => ({ meta, data: { items: [{ id: 'usage-1', request_id: 'request-1', started_at: '2026-09-01T02:00:00Z', model: 'test-model', provider: 'provider', status: 'success', settlement_status: 'settled', billable_amount: amount('10'), quota_covered_amount: amount('8'), wallet_debit_amount: amount('2') }], total: 75, limit: query.limit, offset: query.offset } }))
|
||||
accountApi.wallets.mockResolvedValue({ items: [wallet], total: 1, limit: 1, offset: 0 })
|
||||
accountApi.transactions.mockImplementation(async (_id, query) => ({ wallet, items: [transaction], total: 60, ...query }))
|
||||
accountApi.plans.mockResolvedValue({ items: [plan], total: 1 })
|
||||
})
|
||||
afterEach(() => { cleanup.splice(0).forEach(fn => fn()); vi.useRealTimers(); setI18nLocale('zh-CN') })
|
||||
|
||||
describe('enterprise user accounts', () => {
|
||||
it('shows full-roster financial totals, keeps zero-use users and delegates sorting/pagination', async () => {
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}&attribution_kind=standalone&model=legacy`)
|
||||
expect(root.textContent).toContain('Zero Usage Employee')
|
||||
expect(section(root, '[data-user-summary="consumption"]').textContent).toContain('432.25')
|
||||
expect(section(root, '[data-user-summary="activity"]').textContent).toMatch(/7\s*\/ 62/)
|
||||
expect(root.querySelector('select[aria-label="归属"]')).toBeNull()
|
||||
expect(root.querySelector('[role="tablist"]')).toBeNull()
|
||||
expect(api.users.mock.lastCall?.[0]).toMatchObject({ sort: 'billable_amount', order: 'desc', limit: 25, offset: 0 })
|
||||
expect(api.users.mock.lastCall?.[0]).not.toHaveProperty('attribution_kind')
|
||||
expect(api.users.mock.lastCall?.[0]).not.toHaveProperty('model')
|
||||
button(root, '第 2 页').click()
|
||||
await settle()
|
||||
expect(api.users.mock.lastCall?.[0]).toMatchObject({ offset: 25 })
|
||||
button(root, 'Tokens').click()
|
||||
await settle()
|
||||
expect(api.users.mock.lastCall?.[0]).toMatchObject({ offset: 0, sort: 'total_tokens', order: 'desc' })
|
||||
expect(root.querySelector('a[href*="employee-0"]')).toBeNull()
|
||||
expect(root.querySelector('a[href*="/admin/usage"]')).toBeNull()
|
||||
expect([...section(root, '[data-user-accounts]').querySelectorAll('a, button')].some(item => item.textContent?.trim() === '使用记录')).toBe(false)
|
||||
})
|
||||
it('places reports first and opens reusable account history without leaving the page', async () => {
|
||||
const { root, router } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
const reports = section(root, '[data-user-reports]')
|
||||
const accounts = section(root, '[data-user-accounts]')
|
||||
expect(reports.compareDocumentPosition(accounts) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(accountApi.wallets).not.toHaveBeenCalled()
|
||||
expect(accountApi.plans).not.toHaveBeenCalled()
|
||||
const before = router.currentRoute.value.fullPath
|
||||
const accountButton = button(accounts, '账目')
|
||||
expect(accountButton.textContent?.trim()).toBe('')
|
||||
expect(accountButton.querySelector('svg')).not.toBeNull()
|
||||
accountButton.click()
|
||||
await settle()
|
||||
await settle()
|
||||
const dialog = section(document.body, '[data-user-account-drawer]')
|
||||
expect(router.currentRoute.value.fullPath).toBe(before)
|
||||
expect(accountApi.wallets).toHaveBeenCalledWith({ user_id: employee.user_id, owner_type: 'user', limit: 1, offset: 0 }, expect.any(AbortSignal))
|
||||
expect(accountApi.plans).toHaveBeenCalledWith(employee.user_id, { include_inactive: true }, expect.any(AbortSignal))
|
||||
expect(dialog.textContent).toContain('balance correction')
|
||||
expect(dialog.textContent).toContain('775.5000 → 765.5000')
|
||||
expect(dialog.textContent).toContain('Old monthly plan')
|
||||
expect(dialog.textContent).toContain('已撤销')
|
||||
expect(dialog.textContent).toContain('每日额度')
|
||||
expect(dialog.textContent).not.toContain('撤销套餐')
|
||||
expect(dialog.textContent).not.toContain('发放套餐')
|
||||
button(dialog, '第 2 页').click()
|
||||
await settle()
|
||||
expect(accountApi.transactions.mock.lastCall?.slice(0, 2)).toEqual(['wallet-0', { limit: 25, offset: 25 }])
|
||||
expect(accountApi.plans).toHaveBeenCalledTimes(1)
|
||||
expect(api.users).toHaveBeenCalledTimes(1)
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(1)
|
||||
document.body.querySelector<HTMLButtonElement>('[aria-label="关闭账目"]')?.click()
|
||||
await settle()
|
||||
expect(document.querySelector('[data-user-account-drawer]')).toBeNull()
|
||||
})
|
||||
it('keeps plan history available without a wallet and retries failed wallet lookup', async () => {
|
||||
accountApi.wallets.mockRejectedValueOnce(new Error('wallet unavailable')).mockResolvedValue({ items: [], total: 0 })
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
button(section(root, '[data-user-accounts]'), '账目').click()
|
||||
await settle()
|
||||
const dialog = section(document.body, '[data-user-account-drawer]')
|
||||
expect(dialog.textContent).toContain('wallet unavailable')
|
||||
expect(dialog.textContent).toContain('Old monthly plan')
|
||||
button(dialog, '重试').click()
|
||||
await settle()
|
||||
expect(dialog.textContent).toContain('该用户尚无钱包')
|
||||
expect(accountApi.transactions).not.toHaveBeenCalled()
|
||||
})
|
||||
it('discards closed account requests before opening another user', async () => {
|
||||
let resolveOld: (value: unknown) => void = () => {}
|
||||
accountApi.wallets.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const second = { ...employee, user_id: 'employee-1', username: 'Second employee' }
|
||||
api.users.mockResolvedValue({ meta, data: { total: 2, limit: 25, offset: 0, items: [employee, second], summary: metrics, finance_summary: finance } })
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
const accounts = section(root, '[data-user-accounts]')
|
||||
button(accounts, employee.username).click()
|
||||
await settle()
|
||||
const oldSignal = accountApi.wallets.mock.lastCall?.[1] as AbortSignal
|
||||
document.body.querySelector<HTMLButtonElement>('[aria-label="关闭账目"]')?.click()
|
||||
await settle()
|
||||
expect(oldSignal.aborted).toBe(true)
|
||||
accountApi.wallets.mockResolvedValue({ items: [{ ...wallet, id: 'wallet-1', user_id: second.user_id }], total: 1 })
|
||||
accountApi.transactions.mockResolvedValue({ wallet: { ...wallet, id: 'wallet-1', user_id: second.user_id }, items: [{ ...transaction, description: 'second account only' }], total: 1 })
|
||||
button(accounts, second.username).click()
|
||||
await settle()
|
||||
resolveOld({ items: [wallet], total: 1 })
|
||||
await settle()
|
||||
const dialog = section(document.body, '[data-user-account-drawer]')
|
||||
expect(dialog.textContent).toContain('second account only')
|
||||
expect(accountApi.transactions.mock.calls.every(call => call[0] === 'wallet-1')).toBe(true)
|
||||
})
|
||||
it('loads accounts independently of pending and failed reports', async () => {
|
||||
api.timeseries.mockImplementation(() => new Promise(() => {}))
|
||||
api.breakdown.mockRejectedValue(new Error('models unavailable'))
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
expect(root.textContent).toContain('Zero Usage Employee')
|
||||
expect(section(root, '[data-user-summary="recharge"]').textContent).toContain('1,000.00')
|
||||
expect(section(root, '[data-user-report="models"]').textContent).toContain('模型报表暂不可用')
|
||||
expect(root.textContent).toContain('全站用量')
|
||||
expect(root.textContent).not.toContain('全体用户')
|
||||
})
|
||||
it('keeps missing finance unknown instead of showing a zero balance', async () => {
|
||||
api.users.mockResolvedValue({ meta, data: { total: 1, limit: 25, offset: 0, items: [{ ...employee, finance: null }], summary: { ...metrics, user_count: 1, active_user_count: 0 }, finance_summary: null } })
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
expect(section(root, '[data-user-summary="balance"]').textContent).toMatch(/余额\s*-/)
|
||||
expect(section(root, '[data-user-summary="balance"]').textContent).not.toContain('$0')
|
||||
})
|
||||
it('searches account totals without refetching the installation reports', async () => {
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}`)
|
||||
const input = root.querySelector<HTMLInputElement>('input[aria-label="搜索用户"]')
|
||||
if (!input) throw new Error('Missing user search')
|
||||
input.value = 'Zero'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.closest('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await settle()
|
||||
expect(api.users.mock.lastCall?.[0]).toMatchObject({ search: 'Zero', offset: 0 })
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(1)
|
||||
expect(api.breakdown).toHaveBeenCalledTimes(1)
|
||||
expect(root.textContent).toContain('上方汇总与账目仅包含匹配的用户')
|
||||
})
|
||||
it('shows all sections and paginates payments independently from usage', async () => {
|
||||
const { root } = await mount(UserAnalysisDetail, `/admin/user-stats/member?${range}&tab=usage&model=legacy`)
|
||||
expect(root.querySelector('[role="tablist"]')).toBeNull()
|
||||
expect(root.textContent).toContain('credited-order')
|
||||
expect(root.textContent).toContain('request-1')
|
||||
expect(section(root, '[data-user-consumption]').textContent).not.toContain('规则计价')
|
||||
expect(section(root, '[data-user-consumption]').textContent).toContain('额度抵扣')
|
||||
expect(api.user.mock.lastCall?.[1]).toMatchObject({ user_id: 'member', payment_limit: 25, payment_offset: 0 })
|
||||
expect(api.consumption.mock.lastCall?.[0]).not.toHaveProperty('model')
|
||||
section(root, '[data-user-payments]').querySelector<HTMLButtonElement>('[aria-label="下一页"]')?.click()
|
||||
await settle()
|
||||
expect(api.user.mock.lastCall?.[1]).toMatchObject({ payment_offset: 25 })
|
||||
expect(api.consumption).toHaveBeenCalledTimes(1)
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(1)
|
||||
section(root, '[data-user-consumption]').querySelector<HTMLButtonElement>('[aria-label="下一页"]')?.click()
|
||||
await settle()
|
||||
expect(api.consumption.mock.lastCall?.[0]).toMatchObject({ offset: 25 })
|
||||
expect(api.user).toHaveBeenCalledTimes(2)
|
||||
expect(api.breakdown.mock.lastCall?.[0]).toMatchObject({ user_id: 'member', group_by: 'model', sort: 'total_tokens' })
|
||||
})
|
||||
it('clears money and resets payment pagination when the selected period changes', async () => {
|
||||
const { root, router } = await mount(UserAnalysisDetail, `/admin/user-stats/member?${range}`)
|
||||
section(root, '[data-user-payments]').querySelector<HTMLButtonElement>('[aria-label="下一页"]')?.click()
|
||||
await settle()
|
||||
api.user.mockImplementation(() => new Promise(() => {}))
|
||||
await router.replace('/admin/user-stats/member?from=2026-08-01T00:00:00Z&to=2026-08-02T00:00:00Z&timezone=UTC')
|
||||
await settle()
|
||||
expect(api.user.mock.lastCall?.[1]).toMatchObject({ payment_offset: 0, from: '2026-08-01T00:00:00.000Z' })
|
||||
expect(root.textContent).not.toContain('credited-order')
|
||||
expect(section(root, '[data-user-summary="consumption"]').textContent).not.toContain('432.25')
|
||||
})
|
||||
it('refreshes each section once on activation and every 10 seconds, then stops', async () => {
|
||||
const { root } = await mount(UserStats, '/admin/user-stats?relative_preset=today&timezone=UTC')
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||
root.querySelector<HTMLButtonElement>('[aria-label="开启自动刷新(每 10 秒)"]')?.click()
|
||||
await nextTick()
|
||||
expect(api.users).toHaveBeenCalledTimes(2)
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(2)
|
||||
expect(api.breakdown).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(api.users).toHaveBeenCalledTimes(3)
|
||||
expect(api.timeseries).toHaveBeenCalledTimes(3)
|
||||
expect(api.breakdown).toHaveBeenCalledTimes(3)
|
||||
root.querySelector<HTMLButtonElement>('[aria-label="关闭自动刷新(每 10 秒)"]')?.click()
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(api.users).toHaveBeenCalledTimes(3)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('exports the user report for the selected period and matching user search', async () => {
|
||||
api.exportCsv.mockRejectedValue(new Error('export check'))
|
||||
const { root } = await mount(UserStats, `/admin/user-stats?${range}&search=alice&offset=25`)
|
||||
button(root, '导出用户报表').click()
|
||||
await settle()
|
||||
expect(api.exportCsv.mock.lastCall?.[0]).toBe('users')
|
||||
expect(api.exportCsv.mock.lastCall?.[1]).toMatchObject({ search: 'alice', from: '2026-09-01T00:00:00.000Z', to: '2026-09-02T00:00:00.000Z', timezone: 'UTC', sort: 'billable_amount' })
|
||||
expect(root.textContent).toContain('export check')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<span
|
||||
ref="container"
|
||||
class="block min-w-0"
|
||||
:title="value"
|
||||
><span
|
||||
ref="text"
|
||||
class="inline-block whitespace-nowrap"
|
||||
:style="{ fontSize: `${fontSize}px` }"
|
||||
>{{ value }}</span></span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ value: string; maxFontSize?: number }>(), {
|
||||
maxFontSize: 20,
|
||||
})
|
||||
const container = ref<HTMLElement>()
|
||||
const text = ref<HTMLElement>()
|
||||
const fontSize = ref(props.maxFontSize)
|
||||
let observer: ResizeObserver | null = null
|
||||
let frame: number | null = null
|
||||
let mounted = false
|
||||
|
||||
function fit() {
|
||||
frame = null
|
||||
if (!text.value) return
|
||||
const available = container.value?.clientWidth || 0
|
||||
const width = text.value.getBoundingClientRect().width
|
||||
const renderedFontSize = Number.parseFloat(getComputedStyle(text.value).fontSize)
|
||||
if (!available || !width || !Number.isFinite(renderedFontSize)) return
|
||||
|
||||
// Measure at the rendered size so fitting never briefly restores an oversized value.
|
||||
fontSize.value = Math.max(10, Math.min(
|
||||
props.maxFontSize,
|
||||
Math.floor(available * renderedFontSize / width * 100) / 100,
|
||||
))
|
||||
}
|
||||
|
||||
function scheduleFit() {
|
||||
if (mounted && frame === null) frame = requestAnimationFrame(fit)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mounted = true
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
let previousWidth = -1
|
||||
observer = new ResizeObserver(([entry]) => {
|
||||
const width = entry?.contentRect.width
|
||||
if (width === undefined || width === previousWidth) return
|
||||
previousWidth = width
|
||||
scheduleFit()
|
||||
})
|
||||
if (container.value) observer.observe(container.value)
|
||||
}
|
||||
void document.fonts?.ready.then(scheduleFit)
|
||||
document.fonts?.addEventListener('loadingdone', scheduleFit)
|
||||
scheduleFit()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mounted = false
|
||||
observer?.disconnect()
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
document.fonts?.removeEventListener('loadingdone', scheduleFit)
|
||||
})
|
||||
|
||||
watch(() => [props.value, props.maxFontSize], scheduleFit, { flush: 'post' })
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<section class="min-w-0 space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h2><slot />
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 text-left font-medium">
|
||||
{{ t('维度', 'Dimension') }}
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('请求数', 'Requests') }}
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
Tokens
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('请求成功率', 'Request success') }}
|
||||
</th><th class="py-3 text-right font-medium">
|
||||
{{ t('计费消耗', 'Billable consumption') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id ?? '__unknown__'"
|
||||
class="border-b last:border-0"
|
||||
>
|
||||
<td class="max-w-64 break-words py-3">
|
||||
<button
|
||||
v-if="item.id"
|
||||
class="text-left hover:text-primary hover:underline"
|
||||
@click="$emit('select', item)"
|
||||
>
|
||||
{{ item.label || item.id || t('未知', 'Unknown') }}
|
||||
</button>
|
||||
<span v-else>{{ item.label || t('未知', 'Unknown') }}</span>
|
||||
</td><td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ count(item.request_count) }}
|
||||
</td><td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ count(item.total_tokens) }}
|
||||
</td><td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ percent(item.success_rate?.value) }}
|
||||
</td><td
|
||||
class="whitespace-nowrap py-3 text-right tabular-nums"
|
||||
:title="item.billable_amount?.value ?? ''"
|
||||
>
|
||||
{{ money(item.billable_amount) }}
|
||||
</td>
|
||||
</tr><tr v-if="!items.length">
|
||||
<td
|
||||
colspan="5"
|
||||
class="py-10 text-center text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无数据', 'No data in this range') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OverviewBreakdown } from '@/api/overview'
|
||||
import { count, money, percent } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
defineProps<{ title: string; items: OverviewBreakdown[] }>()
|
||||
defineEmits<{ select: [item: OverviewBreakdown] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOperations"
|
||||
class="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 xl:grid-cols-6"
|
||||
>
|
||||
<OperationsMetricCard
|
||||
v-for="(item, index) in items"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
:note="item.note"
|
||||
:icon="metricIcons[index]"
|
||||
/>
|
||||
</div>
|
||||
<dl
|
||||
v-else
|
||||
class="grid grid-cols-2 gap-x-5 gap-y-5 border-y py-5 sm:grid-cols-3 xl:grid-cols-6"
|
||||
>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</dt>
|
||||
<dd class="mt-2 font-semibold tabular-nums">
|
||||
<MetricValue :value="item.value" />
|
||||
</dd>
|
||||
<p
|
||||
v-if="item.note"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ item.note }}
|
||||
</p>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="metrics.usage_source_counts && mode !== 'cost'"
|
||||
class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<span>{{ t('Token 来源(请求数)', 'Token source (requests)') }}</span>
|
||||
<span
|
||||
v-for="source in tokenSources"
|
||||
:key="source.key"
|
||||
>{{ source.label }} {{ count(metrics.usage_source_counts[source.key]) }}</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Activity, CheckCircle2, Clock, Gauge, Hash, Timer, TriangleAlert, Zap } from 'lucide-vue-next'
|
||||
import type { OverviewMetrics } from '@/api/overview'
|
||||
import { count, money, percent } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import MetricValue from './MetricValue.vue'
|
||||
import OperationsMetricCard from '../operations/OperationsMetricCard.vue'
|
||||
const props = defineProps<{ metrics: OverviewMetrics; mode?: 'global' | 'employee' | 'cost' | 'performance' | 'runtime' }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const isOperations = computed(() => props.mode === 'runtime' || props.mode === 'performance')
|
||||
const metricIcons = computed(() => props.mode === 'performance'
|
||||
? [Activity, CheckCircle2, Clock, Timer, Gauge, Zap]
|
||||
: [Activity, CheckCircle2, Hash, TriangleAlert, Clock, Gauge])
|
||||
const tokenSources = computed(() => [
|
||||
{ key: 'reported' as const, label: t('上游报告', 'Reported') },
|
||||
{ key: 'estimated' as const, label: t('本地估算', 'Estimated') },
|
||||
{ key: 'mixed' as const, label: t('混合', 'Mixed') },
|
||||
{ key: 'unknown' as const, label: t('未知', 'Unknown') },
|
||||
])
|
||||
const items = computed<{ label: string; value: string; note?: string }[]>(() => {
|
||||
const m = props.metrics
|
||||
if (props.mode === 'cost') return [
|
||||
{ label: t('规则计价', 'Rated amount'), value: money(m.rated_amount) },
|
||||
{ label: t('计费消耗', 'Billable consumption'), value: money(m.billable_amount) },
|
||||
{ label: t('额度承担', 'Quota covered'), value: money(m.quota_covered_amount) },
|
||||
{ label: t('钱包消费', 'Wallet consumed'), value: money(m.wallet_consumed_amount) },
|
||||
{ label: t('余额扣减', 'Wallet debited'), value: money(m.wallet_debit_amount) },
|
||||
{ label: t('请求数', 'Requests'), value: count(m.request_count) },
|
||||
]
|
||||
if (props.mode === 'performance') return [
|
||||
{ label: t('请求数', 'Requests'), value: count(m.request_count), note: `${count(m.total_tokens)} Tokens` },
|
||||
{ label: t('请求成功率', 'Request success rate'), value: percent(m.success_rate?.value), note: `${count(m.success_rate?.numerator)}/${count(m.success_rate?.denominator)}` },
|
||||
{ label: t('平均响应 (ms)', 'Avg response (ms)'), value: count(m.latency_ms?.avg) },
|
||||
...(['p50', 'p95', 'p99'] as const).map(key => ({
|
||||
label: `${key.toUpperCase()} (ms)`, value: count(m.latency_ms?.[key]),
|
||||
note: key === 'p99' ? `${count(m.latency_ms?.sample_count)} ${t('样本', 'samples')}` : undefined,
|
||||
})),
|
||||
]
|
||||
if (props.mode === 'runtime') return [
|
||||
{ label: t('请求数', 'Requests'), value: count(m.request_count) },
|
||||
{ label: t('请求成功率', 'Request success rate'), value: percent(m.success_rate?.value), note: `${count(m.success_rate?.numerator)}/${count(m.success_rate?.denominator)}` },
|
||||
{ label: 'Tokens', value: count(m.total_tokens) },
|
||||
{ label: t('失败请求', 'Failed requests'), value: count(m.failed_request_count) },
|
||||
{ label: t('平均响应 (ms)', 'Avg response (ms)'), value: count(m.latency_ms?.avg) },
|
||||
{ label: 'P99 (ms)', value: count(m.latency_ms?.p99), note: `${count(m.latency_ms?.sample_count)} ${t('样本', 'samples')}` },
|
||||
]
|
||||
return [
|
||||
{ label: t('请求数', 'Requests'), value: count(m.request_count) },
|
||||
{ label: t('请求成功率', 'Request success rate'), value: percent(m.success_rate?.value), note: `${count(m.success_rate?.numerator)}/${count(m.success_rate?.denominator)}` },
|
||||
{ label: 'Tokens', value: count(m.total_tokens) },
|
||||
{ label: t('计费消耗', 'Billable consumption'), value: money(m.billable_amount), note: m.billable_amount?.status === 'known_subtotal' ? t('已知小计', 'Known subtotal') : undefined },
|
||||
props.mode === 'employee' ? { label: t('额度承担', 'Quota covered'), value: money(m.quota_covered_amount) } : { label: t('有使用成员', 'Members with usage'), value: count(m.usage_active_users) },
|
||||
props.mode === 'employee' ? { label: t('钱包消费', 'Wallet consumed'), value: money(m.wallet_consumed_amount) } : { label: t('启用成员', 'Enabled members'), value: count(m.enabled_users) },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span>{{ count(total) }} {{ t('条', 'results') }} · {{ total ? count(offset + 1) : 0 }}-{{ count(Math.min(offset + limit, total)) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
class="h-8 rounded-md border bg-background px-2"
|
||||
:aria-label="t('每页条数', 'Page size')"
|
||||
:value="limit"
|
||||
@change="$emit('size', Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option
|
||||
v-for="size in [25, 50, 100]"
|
||||
:key="size"
|
||||
:value="size"
|
||||
>
|
||||
{{ size }}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading || offset <= 0"
|
||||
:title="t('上一页', 'Previous page')"
|
||||
:aria-label="t('上一页', 'Previous page')"
|
||||
@click="$emit('page', Math.max(0, offset - limit))"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading || offset + limit >= total"
|
||||
:title="t('下一页', 'Next page')"
|
||||
:aria-label="t('下一页', 'Next page')"
|
||||
@click="$emit('page', offset + limit)"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import { count } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
defineProps<{ total: number; offset: number; limit: number; loading?: boolean }>()
|
||||
defineEmits<{ page: [offset: number]; size: [limit: number] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-l-2 border-destructive bg-destructive/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<span class="min-w-0 break-words">{{ t('数据加载失败', 'Unable to load data') }}: {{ error }}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@click="$emit('retry')"
|
||||
>
|
||||
{{ t('重试', 'Retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="loading && !meta"
|
||||
class="flex min-h-40 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />{{ t('加载中', 'Loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="meta"
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<span :class="partial ? 'text-amber-700 dark:text-amber-400' : ''">{{ partial ? t('部分数据', 'Partial data') : t('数据完整', 'Complete') }}</span>
|
||||
<span>{{ t('计量覆盖', 'Usage coverage') }} {{ count(meta.coverage.usage_available_count) }}/{{ count(meta.coverage.request_count) }}</span>
|
||||
<span>{{ t('定价覆盖', 'Pricing coverage') }} {{ count(meta.coverage.pricing_available_count) }}/{{ count(meta.coverage.request_count) }}</span>
|
||||
<span>{{ t('已结算', 'Settled') }} {{ count(meta.coverage.settled_count) }}/{{ count(meta.coverage.request_count) }}</span>
|
||||
<span
|
||||
v-if="meta.coverage.unrecoverable_bucket_count"
|
||||
class="text-amber-700 dark:text-amber-400"
|
||||
>{{ t('原始数据已清理,无法恢复的时间桶', 'Source data removed; unrecoverable buckets') }} {{ count(meta.coverage.unrecoverable_bucket_count) }}</span>
|
||||
<span
|
||||
v-if="stale"
|
||||
class="text-amber-700 dark:text-amber-400"
|
||||
>{{ t('数据水位滞后', 'Data delayed') }}</span>
|
||||
<span>{{ t('更新', 'Updated') }} {{ timestamp(meta.generated_at, meta.range.timezone) }}</span>
|
||||
<span v-if="meta.data_through">{{ t('数据截至', 'Data through') }} {{ timestamp(meta.data_through, meta.range.timezone) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="meta?.projection"
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<span v-if="!meta.projection.read_enabled">{{ t('原始事实读取', 'Reading source facts') }}</span>
|
||||
<span>{{ t('聚合覆盖至', 'Aggregate coverage through') }} {{ timestamp(meta.projection.projection_through, meta.range.timezone) }}</span>
|
||||
<span>{{ t('待重建', 'Awaiting rebuild') }} {{ count(meta.projection.dirty_bucket_count) }}</span>
|
||||
<span>{{ t('未建', 'Not built') }} {{ count(meta.projection.missing_bucket_count) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import type { OverviewMeta } from '@/api/overview'
|
||||
import { count, timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
const props = defineProps<{ meta?: OverviewMeta | null; loading?: boolean; error?: string | null }>()
|
||||
defineEmits<{ retry: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const partial = computed(() => props.meta?.coverage.status !== 'complete')
|
||||
const stale = computed(() => !!props.meta?.data_through && Date.parse(props.meta.data_through) < Math.min(Date.parse(props.meta.range.to), Date.now()) - 300_000)
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
<div class="flex max-w-full flex-wrap items-center gap-2">
|
||||
<slot />
|
||||
<TimeRangePicker
|
||||
v-if="presetsOnly"
|
||||
:model-value="pickerRange"
|
||||
:preset-options="['last1hour', 'today', 'last24hours', 'last7days', 'last30days']"
|
||||
:show-granularity="false"
|
||||
preset-only
|
||||
@update:model-value="selectSharedPreset"
|
||||
/>
|
||||
<select
|
||||
v-else
|
||||
class="h-9 rounded-md border bg-background px-2 text-sm"
|
||||
:aria-label="t('时间范围', 'Time range')"
|
||||
:value="selectedPreset"
|
||||
@change="selectPreset"
|
||||
>
|
||||
<option value="custom">
|
||||
{{ t('自定义', 'Custom') }}
|
||||
</option>
|
||||
<option value="last1hour">
|
||||
{{ t('最近 1 小时', 'Last hour') }}
|
||||
</option>
|
||||
<option value="today">
|
||||
{{ t('今天', 'Today') }}
|
||||
</option>
|
||||
<option value="last24hours">
|
||||
{{ t('最近 24 小时', 'Last 24 hours') }}
|
||||
</option>
|
||||
<option value="last7days">
|
||||
{{ t('最近 7 天', 'Last 7 days') }}
|
||||
</option>
|
||||
<option value="last30days">
|
||||
{{ t('最近 30 天', 'Last 30 days') }}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
v-if="!presetsOnly"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-9 w-9"
|
||||
:title="t('精确时间', 'Exact time')"
|
||||
:aria-label="t('精确时间', 'Exact time')"
|
||||
:aria-expanded="editing"
|
||||
@click="editing = !editing"
|
||||
>
|
||||
<CalendarRange class="h-4 w-4" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
:active="refreshActive"
|
||||
:title="refreshTitle || t('刷新', 'Refresh')"
|
||||
:aria-label="refreshTitle || t('刷新', 'Refresh')"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<form
|
||||
v-if="editing && !presetsOnly"
|
||||
class="flex flex-wrap items-end gap-3 border-y py-3"
|
||||
@submit.prevent="apply"
|
||||
>
|
||||
<label class="grid min-w-0 gap-1 text-xs"><span>{{ t('开始', 'From') }}</span><input
|
||||
v-model="fromInput"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-9 max-w-full rounded-md border bg-background px-2 text-sm"
|
||||
></label>
|
||||
<label class="grid min-w-0 gap-1 text-xs"><span>{{ t('结束(不含)', 'To (exclusive)') }}</span><input
|
||||
v-model="toInput"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-9 max-w-full rounded-md border bg-background px-2 text-sm"
|
||||
></label>
|
||||
<label class="grid min-w-0 gap-1 text-xs"><span>{{ t('报表时区', 'Report timezone') }}</span><input
|
||||
v-model="zoneInput"
|
||||
list="overview-timezones"
|
||||
required
|
||||
class="h-9 w-44 max-w-full rounded-md border bg-background px-2 text-sm"
|
||||
><datalist id="overview-timezones"><option
|
||||
v-for="zone in zones"
|
||||
:key="zone"
|
||||
:value="zone"
|
||||
/></datalist></label>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
>
|
||||
{{ t('应用', 'Apply') }}
|
||||
</Button>
|
||||
<p
|
||||
v-if="rangeError"
|
||||
role="alert"
|
||||
class="w-full text-xs text-destructive"
|
||||
>
|
||||
{{ rangeError }}
|
||||
</p>
|
||||
</form>
|
||||
<p
|
||||
v-if="showRange"
|
||||
class="text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ timestamp(range.from, range.timezone) }} - {{ timestamp(range.to, range.timezone) }} · {{ range.timezone }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { CalendarRange } from 'lucide-vue-next'
|
||||
import { Button, RefreshButton } from '@/components/ui'
|
||||
import TimeRangePicker from '@/components/common/TimeRangePicker.vue'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
import type { OverviewRange } from '@/api/overview'
|
||||
import { browserTimezone, isTimezone, presetRange, zonedInput, zonedInstant } from '../query'
|
||||
import { timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
range: OverviewRange
|
||||
loading?: boolean
|
||||
preset?: string | null
|
||||
presetsOnly?: boolean
|
||||
showRange?: boolean
|
||||
refreshActive?: boolean
|
||||
refreshTitle?: string
|
||||
}>(), { preset: undefined, showRange: true, refreshActive: undefined, refreshTitle: undefined })
|
||||
const emit = defineEmits<{ 'update:range': [value: OverviewRange, preset?: string]; refresh: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const pickerRange = computed<DateRangeParams>(() => props.preset
|
||||
? { preset: props.preset, timezone: props.range.timezone, granularity: 'day' }
|
||||
: { ...props.range, granularity: 'day' })
|
||||
function selectSharedPreset(value: DateRangeParams) {
|
||||
if (value.preset) emit('update:range', presetRange(value.preset, props.range.timezone), value.preset)
|
||||
}
|
||||
const editing = ref(false)
|
||||
const selectedPreset = ref('custom')
|
||||
const fromInput = ref('')
|
||||
const toInput = ref('')
|
||||
const zoneInput = ref('UTC')
|
||||
const rangeError = ref('')
|
||||
const zones = [...new Set([browserTimezone(), 'UTC', 'Asia/Shanghai', 'America/New_York', 'Europe/Berlin'])]
|
||||
function syncRangeInputs(value: OverviewRange) {
|
||||
fromInput.value = zonedInput(value.from, value.timezone)
|
||||
toInput.value = zonedInput(value.to, value.timezone)
|
||||
zoneInput.value = value.timezone
|
||||
}
|
||||
watch(() => props.range, value => { if (!editing.value) syncRangeInputs(value) }, { immediate: true })
|
||||
watch(editing, open => { if (open) syncRangeInputs(props.range) })
|
||||
watch(() => props.preset, value => { if (value !== undefined) selectedPreset.value = value || 'custom' }, { immediate: true })
|
||||
function selectPreset(event: Event) {
|
||||
selectedPreset.value = (event.target as HTMLSelectElement).value
|
||||
if (selectedPreset.value === 'custom') { editing.value = true; return }
|
||||
editing.value = false
|
||||
emit('update:range', presetRange(selectedPreset.value, props.range.timezone), selectedPreset.value)
|
||||
}
|
||||
function apply() {
|
||||
const zone = zoneInput.value.trim()
|
||||
const from = zonedInstant(fromInput.value, zone)
|
||||
const to = zonedInstant(toInput.value, zone)
|
||||
if (!isTimezone(zone) || !from || !to || from >= to) {
|
||||
rangeError.value = t('请输入有效时区与起止时间;夏令时跳过的时间不可用。', 'Enter a valid timezone and time range. Times skipped by daylight saving are invalid.')
|
||||
return
|
||||
}
|
||||
rangeError.value = ''
|
||||
selectedPreset.value = 'custom'
|
||||
emit('update:range', { from, to, timezone: zone })
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<section class="min-w-0 space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ title || t('使用趋势', 'Usage trend') }}
|
||||
</h2><select
|
||||
v-model="metric"
|
||||
:aria-label="t('趋势指标', 'Trend metric')"
|
||||
class="h-8 rounded-md border bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="requests">
|
||||
{{ t('请求数', 'Requests') }}
|
||||
</option><option value="tokens">
|
||||
Tokens
|
||||
</option><option value="billable">
|
||||
{{ t('计费消耗', 'Billable consumption') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="!points.length"
|
||||
class="flex h-64 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无数据', 'No data in this range') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-64 min-w-0"
|
||||
>
|
||||
<LineChart :data="chart" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { OverviewSeriesPoint } from '@/api/overview'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import { timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
const props = defineProps<{ points: OverviewSeriesPoint[]; timezone: string; title?: string }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const metric = ref('requests')
|
||||
const chart = computed(() => ({ labels: props.points.map(p => timestamp(p.bucket_start, props.timezone)), datasets: [{
|
||||
label: metric.value === 'tokens' ? 'Tokens' : metric.value === 'billable' ? t('计费消耗 (USD)', 'Billable consumption (USD)') : t('请求数', 'Requests'),
|
||||
data: props.points.map(p => metric.value === 'tokens' ? p.total_tokens : metric.value === 'billable' ? (p.billable_amount?.value == null ? null : Number(p.billable_amount.value)) : p.request_count),
|
||||
borderColor: '#0d9488', backgroundColor: '#0d9488', pointRadius: props.points.length > 60 ? 0 : 2, borderWidth: 2, tension: 0.15,
|
||||
}] }))
|
||||
</script>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
:title="t('登记提供商支出', 'Record provider expense')"
|
||||
:description="t('记录已支付的充值、套餐或其他费用', 'Record a paid recharge, subscription or other expense')"
|
||||
size="lg"
|
||||
:persistent="saving"
|
||||
@update:open="value => !saving && emit('update:open', value)"
|
||||
>
|
||||
<form
|
||||
id="provider-expense-form"
|
||||
class="grid gap-4 py-2 sm:grid-cols-2"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<label class="grid gap-2 text-sm sm:col-span-2">
|
||||
{{ t('提供商', 'Provider') }}
|
||||
<select
|
||||
v-model="draft.provider_id"
|
||||
required
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
disabled
|
||||
>{{ t('选择提供商', 'Select a provider') }}</option>
|
||||
<option
|
||||
v-for="provider in providers"
|
||||
:key="provider.provider_id"
|
||||
:value="provider.provider_id"
|
||||
>{{ provider.provider_name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('支出类型', 'Expense type') }}
|
||||
<select
|
||||
v-model="draft.kind"
|
||||
class="h-10 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="recharge">{{ t('余额充值', 'Balance recharge') }}</option>
|
||||
<option value="subscription">{{ t('套餐 / 续费', 'Subscription / renewal') }}</option>
|
||||
<option value="other">{{ t('其他支出', 'Other expense') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('币种', 'Currency') }}
|
||||
<input
|
||||
v-model="draft.currency"
|
||||
maxlength="3"
|
||||
pattern="[A-Za-z]{3}"
|
||||
required
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3 uppercase"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('实付金额', 'Amount paid') }}
|
||||
<input
|
||||
v-model="draft.amount"
|
||||
inputmode="decimal"
|
||||
placeholder="0.00"
|
||||
required
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('付款时间', 'Paid at') }} · {{ timezone }}
|
||||
<input
|
||||
v-model="draft.paid_at"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<template v-if="draft.kind === 'subscription'">
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('套餐开始(选填)', 'Plan starts (optional)') }}
|
||||
<input
|
||||
v-model="draft.period_start"
|
||||
type="date"
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm">
|
||||
{{ t('套餐到期(选填)', 'Plan expires (optional)') }}
|
||||
<input
|
||||
v-model="draft.period_end"
|
||||
type="date"
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</template>
|
||||
<label class="grid gap-2 text-sm sm:col-span-2">
|
||||
{{ t('账单 / 交易号(选填)', 'Invoice / reference (optional)') }}
|
||||
<input
|
||||
v-model="draft.external_reference"
|
||||
maxlength="200"
|
||||
class="h-10 min-w-0 rounded-lg border bg-background px-3"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm sm:col-span-2">
|
||||
{{ t('备注(选填)', 'Note (optional)') }}
|
||||
<textarea
|
||||
v-model="draft.note"
|
||||
maxlength="1000"
|
||||
rows="2"
|
||||
class="min-w-0 resize-y rounded-lg border bg-background p-3"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</label>
|
||||
<p
|
||||
v-if="error"
|
||||
class="text-sm text-destructive sm:col-span-2"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<Button
|
||||
type="submit"
|
||||
form="provider-expense-form"
|
||||
:disabled="saving || !providers.length"
|
||||
>
|
||||
{{ saving ? t('保存中…', 'Saving…') : t('保存记录', 'Save record') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
@click="emit('update:open', false)"
|
||||
>
|
||||
{{ t('取消', 'Cancel') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Button, Dialog } from '@/components/ui'
|
||||
import { providerFinanceApi, type ProviderAccount, type ProviderExpenseKind } from '@/api/providerFinance'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { zonedInput, zonedInstant } from '../query'
|
||||
|
||||
const props = defineProps<{ open: boolean; providers: ProviderAccount[]; timezone: string }>()
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean]; saved: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const draft = reactive({ provider_id: '', kind: 'recharge' as ProviderExpenseKind, amount: '', currency: 'USD', paid_at: '', period_start: '', period_end: '', note: '', external_reference: '' })
|
||||
let pending: { body: string; id: string } | null = null
|
||||
watch(() => props.open, open => {
|
||||
if (open && !draft.paid_at) draft.paid_at = zonedInput(new Date(), props.timezone)
|
||||
})
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
error.value = ''
|
||||
const paidAt = zonedInstant(draft.paid_at, props.timezone)
|
||||
const start = draft.kind === 'subscription' && draft.period_start ? zonedInstant(`${draft.period_start}T00:00`, props.timezone) : null
|
||||
const end = draft.kind === 'subscription' && draft.period_end ? zonedInstant(`${draft.period_end}T00:00`, props.timezone) : null
|
||||
if (!draft.provider_id || !paidAt || !/^\d{1,12}(\.\d{1,8})?$/.test(draft.amount.trim()) || Number(draft.amount) <= 0 || !/^[A-Za-z]{3}$/.test(draft.currency)) {
|
||||
error.value = t('请填写提供商、有效付款时间、正数金额及三位币种代码。', 'Enter a provider, valid payment time, positive amount and three-letter currency code.')
|
||||
return
|
||||
}
|
||||
if (draft.kind === 'subscription' && ((Boolean(start) !== Boolean(end)) || (draft.period_start && !start) || (draft.period_end && !end) || (start && end && start >= end))) {
|
||||
error.value = t('请同时填写套餐开始和到期时间,且到期时间晚于开始时间。', 'Enter both plan dates, with expiry after the start.')
|
||||
return
|
||||
}
|
||||
const input = { provider_id: draft.provider_id, kind: draft.kind, amount: draft.amount.trim(), currency: draft.currency.toUpperCase(), paid_at: paidAt, period_start: start, period_end: end, note: draft.note.trim() || null, external_reference: draft.external_reference.trim() || null }
|
||||
const body = JSON.stringify(input)
|
||||
if (!pending || pending.body !== body) pending = { body, id: crypto.randomUUID() }
|
||||
saving.value = true
|
||||
try {
|
||||
await providerFinanceApi.record({ ...input, client_request_id: pending.id })
|
||||
pending = null
|
||||
Object.assign(draft, { provider_id: '', kind: 'recharge', amount: '', currency: 'USD', paid_at: '', period_start: '', period_end: '', note: '', external_reference: '' })
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
} catch (cause) {
|
||||
const failure = cause as { response?: { data?: { detail?: string; message?: string } }; message?: string }
|
||||
error.value = failure.response?.data?.detail || failure.response?.data?.message || failure.message || t('保存失败,请重试', 'Save failed; try again')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-provider-expenses
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('充值与续费记录', 'Recharges and renewals') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('按付款时间统计已登记支出,作废记录不计入汇总', 'Recorded expenses by payment date; voided entries excluded') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="exporting || !data?.total"
|
||||
@click="exportReport"
|
||||
>
|
||||
<Download class="mr-1.5 h-3.5 w-3.5" />{{ t('导出支出报表', 'Export expenses') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="!canRecord"
|
||||
@click="emit('record')"
|
||||
>
|
||||
<Plus class="mr-1.5 h-3.5 w-3.5" />{{ t('登记支出', 'Record expense') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="actionError"
|
||||
class="text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{{ actionError }}
|
||||
</p>
|
||||
<Card class="min-w-0 overflow-hidden">
|
||||
<div class="overflow-x-auto px-4 sm:px-5">
|
||||
<table class="w-full min-w-[760px] text-sm">
|
||||
<thead class="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 text-left font-medium">
|
||||
{{ t('提供商 / 类型', 'Provider / type') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('实付金额', 'Amount paid') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('付款时间', 'Payment date') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('套餐周期', 'Plan period') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('账单与备注', 'Reference and note') }}
|
||||
</th>
|
||||
<th class="py-3 text-right font-medium">
|
||||
{{ t('操作', 'Action') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr
|
||||
v-for="item in data?.items || []"
|
||||
:key="item.id"
|
||||
>
|
||||
<td class="max-w-44 break-words py-3 font-medium">
|
||||
{{ item.provider_name }}<p class="mt-1 text-xs font-normal text-muted-foreground">
|
||||
{{ kindLabel(item.kind) }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ amount(item) }}<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ item.currency }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-xs">
|
||||
{{ timestamp(item.paid_at, range.timezone) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-xs text-muted-foreground">
|
||||
{{ item.period_start ? timestamp(item.period_start, range.timezone) : '-' }}<br>{{ item.period_end ? timestamp(item.period_end, range.timezone) : '-' }}
|
||||
</td>
|
||||
<td class="max-w-56 break-words px-3 py-3 text-xs">
|
||||
{{ item.external_reference || '-' }}<p
|
||||
v-if="item.note"
|
||||
class="mt-1 text-muted-foreground"
|
||||
>
|
||||
{{ item.note }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`${t('作废', 'Void')} ${item.provider_name} ${amount(item)}`"
|
||||
@click="voiding = item"
|
||||
>
|
||||
{{ t('作废', 'Void') }}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!data?.items.length">
|
||||
<td
|
||||
colspan="6"
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{{ loading ? t('加载支出记录…', 'Loading expenses…') : data ? t('此时段尚未登记支出', 'No recorded expenses in this range') : t('暂未取得支出记录', 'Expense records unavailable') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="data"
|
||||
class="px-4 pb-3 sm:px-5"
|
||||
>
|
||||
<OverviewPagination
|
||||
:total="data.total"
|
||||
:offset="data.offset"
|
||||
:limit="data.limit"
|
||||
:loading="loading"
|
||||
@page="value => emit('page', value)"
|
||||
@size="value => emit('size', value)"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Dialog
|
||||
:open="Boolean(voiding)"
|
||||
:title="t('作废支出记录', 'Void expense record')"
|
||||
:persistent="saving"
|
||||
@update:open="value => !value && !saving && (voiding = null)"
|
||||
>
|
||||
<p class="py-3 text-sm">
|
||||
{{ t('确认作废这笔记录?记录会保留用于审计,并从支出汇总中扣除。', 'Void this record? It will remain in the audit history and be excluded from expense totals.') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="voiding"
|
||||
class="pb-3 font-medium"
|
||||
>
|
||||
{{ voiding.provider_name }} · {{ amount(voiding) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="voidError"
|
||||
class="pb-3 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{{ voidError }}
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="saving"
|
||||
@click="confirmVoid"
|
||||
>
|
||||
{{ saving ? t('处理中…', 'Processing…') : t('确认作废', 'Void record') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
@click="voiding = null"
|
||||
>
|
||||
{{ t('取消', 'Cancel') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onScopeDispose, ref, watch } from 'vue'
|
||||
import { Download, Plus } from 'lucide-vue-next'
|
||||
import { Button, Card, Dialog } from '@/components/ui'
|
||||
import { providerFinanceApi, type ProviderExpense, type ProviderExpenseKind, type ProviderExpenses } from '@/api/providerFinance'
|
||||
import type { OverviewRange } from '@/api/overview'
|
||||
import OverviewPagination from '../components/OverviewPagination.vue'
|
||||
import { money, timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
const props = defineProps<{ data: ProviderExpenses | null; range: OverviewRange; offset: number; loading: boolean; canRecord: boolean }>()
|
||||
const emit = defineEmits<{ record: []; page: [offset: number]; size: [limit: number]; changed: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const exporting = ref(false)
|
||||
const actionError = ref('')
|
||||
const voidError = ref('')
|
||||
const voiding = ref<ProviderExpense | null>(null)
|
||||
const saving = ref(false)
|
||||
let controller: AbortController | null = null
|
||||
watch(voiding, () => { voidError.value = '' })
|
||||
watch(() => JSON.stringify(props.range), () => { actionError.value = '' })
|
||||
function amount(item: ProviderExpense) { return money({ value: item.amount, currency: item.currency, basis: 'provider_expense', status: 'known' }) }
|
||||
function kindLabel(kind: ProviderExpenseKind) { return kind === 'subscription' ? t('套餐 / 续费', 'Subscription / renewal') : kind === 'recharge' ? t('余额充值', 'Balance recharge') : t('其他支出', 'Other expense') }
|
||||
function message(cause: unknown) {
|
||||
const error = cause as { response?: { data?: { detail?: string; message?: string } }; message?: string }
|
||||
return error.response?.data?.detail || error.response?.data?.message || error.message || t('操作失败,请重试', 'Action failed; try again')
|
||||
}
|
||||
async function confirmVoid() {
|
||||
if (!voiding.value || saving.value) return
|
||||
saving.value = true
|
||||
try { await providerFinanceApi.void(voiding.value.id); voiding.value = null; emit('changed') }
|
||||
catch (cause) { voidError.value = message(cause) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function exportReport() {
|
||||
if (exporting.value) return
|
||||
actionError.value = ''
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await providerFinanceApi.exportExpenses(props.range, controller.signal)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `aether-provider-expenses-${props.range.from.slice(0, 10)}.csv`
|
||||
anchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted) return
|
||||
const body = (cause as { response?: { data?: Blob } }).response?.data
|
||||
if (body instanceof Blob) {
|
||||
try { actionError.value = JSON.parse(await body.text()).detail || message(cause) }
|
||||
catch { actionError.value = message(cause) }
|
||||
} else actionError.value = message(cause)
|
||||
} finally { exporting.value = false }
|
||||
}
|
||||
onScopeDispose(() => controller?.abort())
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-provider-accounts
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('账户余额与套餐', 'Balances and subscriptions') }}
|
||||
</h2>
|
||||
<span class="text-xs text-muted-foreground">{{ t('当前账户状态', 'Current account status') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="accounts.length"
|
||||
class="grid grid-cols-1 gap-4 xl:grid-cols-2"
|
||||
>
|
||||
<Card
|
||||
v-for="account in orderedAccounts"
|
||||
:key="account.provider_id"
|
||||
class="min-w-0 space-y-4 p-4 sm:p-5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="break-words text-sm font-semibold">
|
||||
{{ account.provider_name }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ billingLabel(account.billing_type) }}<span v-if="!account.is_active"> · {{ t('已停用', 'Disabled') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="attention(account)"
|
||||
class="shrink-0 rounded-full bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
>{{ attention(account) }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('上游余额', 'Upstream balance') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xl font-semibold tabular-nums">
|
||||
{{ amount(account.balance?.available, account.balance?.currency) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ account.balance?.observed_at ? `${t('更新于', 'Updated')} ${timestamp(account.balance.observed_at, timezone)}` : t('暂无余额快照', 'No balance snapshot') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="account.balance && account.balance.status !== 'success'"
|
||||
class="mt-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ t('上次查询未成功', 'Last query unsuccessful') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('配置套餐剩余额度', 'Configured plan remaining') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xl font-semibold tabular-nums">
|
||||
{{ amount(account.quota?.remaining, account.quota?.currency) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ account.quota ? `${t('周期总额度', 'Period quota')} ${amount(account.quota.limit, account.quota.currency)}` : t('未配置套餐额度', 'No configured plan quota') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="account.quota"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t('已使用', 'Used') }} {{ amount(account.quota.used, account.quota.currency) }}</span>
|
||||
<span>{{ account.quota.expires_at ? `${t('到期', 'Expires')} ${timestamp(account.quota.expires_at, timezone)}` : t('未设置到期时间', 'No expiry configured') }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:class="quotaRatio(account) >= 90 ? 'bg-amber-500' : 'bg-primary/65'"
|
||||
:style="{ width: `${quotaRatio(account)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="account.balance?.plan_name || account.balance?.subscriptions.length"
|
||||
class="space-y-3 border-t pt-3"
|
||||
>
|
||||
<p
|
||||
v-if="account.balance.plan_name"
|
||||
class="text-xs font-medium"
|
||||
>
|
||||
{{ t('上游套餐', 'Upstream plan') }} · {{ account.balance.plan_name }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(plan, index) in account.balance.subscriptions"
|
||||
:key="index"
|
||||
class="space-y-1 text-xs"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between gap-2">
|
||||
<span class="font-medium">{{ plan.group_name || t('订阅套餐', 'Subscription') }}</span>
|
||||
<span class="text-muted-foreground">{{ plan.expires_at ? timestamp(plan.expires_at, timezone) : t('未提供到期时间', 'No expiry reported') }}</span>
|
||||
</div>
|
||||
<p class="text-muted-foreground">
|
||||
{{ planUsage(plan) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card
|
||||
v-else
|
||||
class="p-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无提供商账户', 'No provider accounts') }}
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Card } from '@/components/ui'
|
||||
import type { ProviderAccount, ProviderAccountSubscription } from '@/api/providerFinance'
|
||||
import { money, timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
const props = defineProps<{ accounts: ProviderAccount[]; timezone: string; observedAt?: string }>()
|
||||
const { t } = useOverviewI18n()
|
||||
function amount(value: string | number | null | undefined, currency: string | null = 'USD') { return value == null || !currency || !/^[a-z]{3}$/i.test(currency) ? '-' : money({ value: String(value), currency, basis: 'provider_account', status: 'known' }) }
|
||||
function billingLabel(value: string | null) {
|
||||
return value === 'monthly_quota' ? t('周期套餐', 'Period plan') : value === 'free_tier' ? t('免费额度', 'Free tier') : value === 'pay_as_you_go' ? t('按量付费', 'Pay as you go') : t('未设置计费方式', 'Billing type not configured')
|
||||
}
|
||||
function quotaRatio(account: ProviderAccount) {
|
||||
return account.quota && Number(account.quota.limit) > 0 ? Math.min(100, Math.max(0, Number(account.quota.used) / Number(account.quota.limit) * 100)) : 0
|
||||
}
|
||||
function attention(account: ProviderAccount) {
|
||||
if (!account.is_active) return ''
|
||||
const now = props.observedAt ? Date.parse(props.observedAt) : Date.now()
|
||||
const end = account.quota?.expires_at ? Date.parse(account.quota.expires_at) : NaN
|
||||
if (Number.isFinite(end) && end <= now) return t('套餐已到期', 'Plan expired')
|
||||
if (Number.isFinite(end) && end - now <= 7 * 86_400_000) return t('7 天内到期', 'Expires within 7 days')
|
||||
if (account.quota?.remaining != null && Number(account.quota.remaining) <= 0) return t('套餐额度用尽', 'Plan quota exhausted')
|
||||
if (quotaRatio(account) >= 90) return t('套餐剩余不足 10%', 'Under 10% quota remaining')
|
||||
return ''
|
||||
}
|
||||
const orderedAccounts = computed(() => [...props.accounts].sort((a, b) => Number(Boolean(attention(b))) - Number(Boolean(attention(a))) || a.provider_name.localeCompare(b.provider_name)))
|
||||
function planUsage(plan: ProviderAccountSubscription) {
|
||||
return [
|
||||
[t('日额度', 'Daily'), plan.daily_used_usd, plan.daily_limit_usd],
|
||||
[t('周额度', 'Weekly'), plan.weekly_used_usd, plan.weekly_limit_usd],
|
||||
[t('月额度', 'Monthly'), plan.monthly_used_usd, plan.monthly_limit_usd],
|
||||
].filter(([, , limit]) => limit != null).map(([label, used, limit]) => `${label} ${amount(used)} / ${amount(limit)}`).join(' · ') || t('暂无额度明细', 'Quota details unavailable')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<section class="space-y-3 border-t pt-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('提供商套餐额度', 'Provider plan quota') }}
|
||||
</h2>
|
||||
<span class="text-xs text-muted-foreground">{{ t('当前配置 · 全站', 'Current configuration · Site-wide') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading"
|
||||
:title="t('刷新额度', 'Refresh quota')"
|
||||
:aria-label="t('刷新额度', 'Refresh quota')"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<OverviewStatus
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@retry="refresh"
|
||||
/>
|
||||
<div
|
||||
v-if="data?.providers.length"
|
||||
class="overflow-x-auto"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-y text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 text-left">
|
||||
{{ t('提供商', 'Provider') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right">
|
||||
{{ t('已使用 / 额度', 'Used / Quota') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right">
|
||||
{{ t('剩余', 'Remaining') }}
|
||||
</th>
|
||||
<th class="min-w-32 px-3 py-3 text-left">
|
||||
{{ t('使用进度', 'Utilization') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left">
|
||||
{{ t('到期时间', 'Expires') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left">
|
||||
{{ t('预计耗尽', 'Estimated depletion') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="provider in data.providers"
|
||||
:key="provider.id"
|
||||
class="border-b"
|
||||
>
|
||||
<td class="max-w-56 break-words py-3 font-medium">
|
||||
{{ provider.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ usd(provider.used_usd) }} / {{ usd(provider.quota_usd) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-right tabular-nums">
|
||||
{{ usd(provider.remaining_usd) }}
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<span class="text-xs tabular-nums">{{ percent(provider.usage_percent / 100) }}</span><div class="mt-1 h-1.5 w-24 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
class="h-full"
|
||||
:class="provider.usage_percent >= 90 ? 'bg-amber-500' : 'bg-emerald-600'"
|
||||
:style="{ width: `${Math.min(100, Math.max(0, provider.usage_percent))}%` }"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-xs">
|
||||
{{ timestamp(provider.quota_expires_at, timezone) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3 text-xs">
|
||||
{{ timestamp(provider.estimated_exhaust_at, timezone) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="data"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无已配置的提供商额度', 'No provider quota configured') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('读取时间', 'Retrieved') }} {{ timestamp(data.retrievedAt, timezone) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { Button } from '@/components/ui'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { money, percent, timestamp } from '../format'
|
||||
defineProps<{ timezone: string }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { data, loading, error, refresh } = useOverviewRequest(() => 'provider-quota', async () => ({ ...await adminApi.getQuotaUsage(), retrievedAt: new Date().toISOString() }))
|
||||
function usd(value: number) { return money({ value: String(value), currency: 'USD', basis: 'provider_plan', status: 'known' }) }
|
||||
</script>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('统计周期', 'Statistics period') }}
|
||||
</h2>
|
||||
<div class="flex max-w-full flex-wrap items-center gap-2">
|
||||
<select
|
||||
:value="preset"
|
||||
:aria-label="t('图表时间范围', 'Chart time range')"
|
||||
class="h-8 rounded-md border bg-background px-2 text-xs"
|
||||
@change="selectPreset"
|
||||
>
|
||||
<option value="last7days">
|
||||
{{ t('最近 7 天', 'Last 7 days') }}
|
||||
</option>
|
||||
<option value="last30days">
|
||||
{{ t('最近 30 天', 'Last 30 days') }}
|
||||
</option>
|
||||
<option value="today">
|
||||
{{ t('今天', 'Today') }}
|
||||
</option>
|
||||
<option value="custom">
|
||||
{{ t('自定义', 'Custom') }}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="t('精确时间', 'Exact time')"
|
||||
:aria-label="t('精确时间', 'Exact time')"
|
||||
:aria-expanded="editing"
|
||||
@click="editing = !editing"
|
||||
>
|
||||
<CalendarRange class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="loading"
|
||||
:title="t('刷新图表', 'Refresh charts')"
|
||||
:aria-label="t('刷新图表', 'Refresh charts')"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-4 w-4"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
v-if="editing"
|
||||
class="flex max-w-full flex-wrap items-end gap-3 border-y py-3"
|
||||
@submit.prevent="apply"
|
||||
>
|
||||
<label class="grid w-48 max-w-full gap-1 text-xs"><span>{{ t('开始', 'From') }}</span><input
|
||||
v-model="fromInput"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-9 w-full min-w-0 rounded-md border bg-background px-2 text-sm"
|
||||
></label>
|
||||
<label class="grid w-48 max-w-full gap-1 text-xs"><span>{{ t('结束(不含)', 'To (exclusive)') }}</span><input
|
||||
v-model="toInput"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="h-9 w-full min-w-0 rounded-md border bg-background px-2 text-sm"
|
||||
></label>
|
||||
<label class="grid w-44 max-w-full gap-1 text-xs"><span>{{ t('报表时区', 'Report timezone') }}</span><input
|
||||
v-model="zoneInput"
|
||||
list="dashboard-timezones"
|
||||
required
|
||||
class="h-9 w-full min-w-0 rounded-md border bg-background px-2 text-sm"
|
||||
><datalist id="dashboard-timezones"><option
|
||||
v-for="zone in zones"
|
||||
:key="zone"
|
||||
:value="zone"
|
||||
/></datalist></label>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
>
|
||||
{{ t('应用', 'Apply') }}
|
||||
</Button>
|
||||
<p
|
||||
v-if="rangeError"
|
||||
role="alert"
|
||||
class="w-full text-xs text-destructive"
|
||||
>
|
||||
{{ rangeError }}
|
||||
</p>
|
||||
</form>
|
||||
<p class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ timestamp(range.from, range.timezone) }} - {{ timestamp(range.to, range.timezone) }} · {{ range.timezone }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { CalendarRange, RefreshCw } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import type { OverviewRange } from '@/api/overview'
|
||||
import { browserTimezone, isTimezone, presetRange, zonedInput, zonedInstant } from '../query'
|
||||
import { timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
|
||||
const props = defineProps<{ range: OverviewRange; loading?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:range': [value: OverviewRange]; refresh: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const editing = ref(false)
|
||||
const preset = ref('custom')
|
||||
const fromInput = ref('')
|
||||
const toInput = ref('')
|
||||
const zoneInput = ref('UTC')
|
||||
const rangeError = ref('')
|
||||
const zones = [...new Set([browserTimezone(), 'UTC', 'Asia/Shanghai', 'America/New_York', 'Europe/Berlin'])]
|
||||
watch(() => props.range, value => {
|
||||
fromInput.value = zonedInput(value.from, value.timezone)
|
||||
toInput.value = zonedInput(value.to, value.timezone)
|
||||
zoneInput.value = value.timezone
|
||||
const duration = Date.parse(value.to) - Date.parse(value.from)
|
||||
const recent = Math.abs(Date.now() - Date.parse(value.to)) < 120_000
|
||||
preset.value = recent && duration === 7 * 86_400_000 ? 'last7days' : recent && duration === 30 * 86_400_000 ? 'last30days'
|
||||
: recent && value.from === presetRange('today', value.timezone, new Date(value.to)).from ? 'today' : 'custom'
|
||||
}, { immediate: true })
|
||||
function selectPreset(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
preset.value = value
|
||||
if (value === 'custom') { editing.value = true; return }
|
||||
editing.value = false
|
||||
emit('update:range', presetRange(value, props.range.timezone))
|
||||
}
|
||||
function apply() {
|
||||
const zone = zoneInput.value.trim()
|
||||
const from = zonedInstant(fromInput.value, zone)
|
||||
const to = zonedInstant(toInput.value, zone)
|
||||
if (!isTimezone(zone) || !from || !to || from >= to) {
|
||||
rangeError.value = t('请输入有效时区与起止时间;夏令时跳过的时间不可用。', 'Enter a valid timezone and time range. Times skipped by daylight saving are invalid.')
|
||||
return
|
||||
}
|
||||
rangeError.value = ''
|
||||
emit('update:range', { from, to, timezone: zone })
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="grid min-w-0 grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<section class="min-w-0 space-y-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('每日模型费用', 'Daily model cost') }}
|
||||
</h2>
|
||||
<div class="h-[280px] min-w-0">
|
||||
<BarChart
|
||||
v-if="modelChart.datasets.length && hasModelAmount"
|
||||
:data="modelChart"
|
||||
:options="modelOptions"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground"
|
||||
>
|
||||
{{ emptyCostLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="min-w-0 space-y-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('提供商费用分布', 'Provider cost distribution') }}
|
||||
</h2>
|
||||
<div class="h-[280px] min-w-0">
|
||||
<DoughnutChart
|
||||
v-if="slices.length"
|
||||
:data="providerChart"
|
||||
:options="providerOptions"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground"
|
||||
>
|
||||
{{ emptyCostLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<p
|
||||
v-if="partial"
|
||||
class="text-xs text-amber-700 lg:col-span-2 dark:text-amber-400"
|
||||
>
|
||||
{{ t('费用为已知小计,分布占比按已知金额计算', 'Costs are known subtotals; distribution shares use known amounts') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import type { OverviewDashboardCharts } from '@/api/overview'
|
||||
import BarChart from '@/components/charts/BarChart.vue'
|
||||
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
||||
import { money } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { amountValue } from './amount'
|
||||
import { chartColors, chartDate, modelDatasets, providerSlices } from './charts'
|
||||
|
||||
const props = defineProps<{ data: OverviewDashboardCharts; timezone: string }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const currency = computed(() => props.data.summary.billable_amount.currency)
|
||||
const formatCost = (value: number) => money({ value: String(value), currency: currency.value, basis: 'billable', status: 'known' })
|
||||
const partial = computed(() => {
|
||||
const rows = [props.data.summary, ...props.data.models, ...props.data.providers]
|
||||
return rows.some(row => amountValue(row.billable_amount) !== null)
|
||||
&& rows.some(row => row.billable_amount.status !== 'known')
|
||||
})
|
||||
const emptyCostLabel = computed(() => props.data.summary.request_count === 0 ? t('暂无使用数据', 'No usage data')
|
||||
: amountValue(props.data.summary.billable_amount) === null ? t('费用尚未确认', 'Cost not yet known') : t('此周期暂无计费费用', 'No billable cost in this period'))
|
||||
const modelChart = computed<ChartData<'bar'>>(() => ({
|
||||
labels: props.data.series.map(day => chartDate(day.bucket_start, props.timezone)),
|
||||
datasets: modelDatasets(props.data, t('未知模型', 'Unknown model'), t('其他模型', 'Other models')),
|
||||
}))
|
||||
const hasModelAmount = computed(() => modelChart.value.datasets.some(dataset => dataset.data.some(value => typeof value === 'number' && value !== 0)))
|
||||
const modelOptions = computed<ChartOptions<'bar'>>(() => ({
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 0 } },
|
||||
y: { stacked: true, ticks: { font: { size: 10 } }, title: { display: true, text: `${t('费用', 'Cost')} (${currency.value})`, font: { size: 10 } } },
|
||||
},
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { font: { size: 10 }, boxWidth: 10, padding: 10,
|
||||
generateLabels: chart => chart.data.datasets.map((dataset, index) => ({ text: shorten(dataset.label || ''), fillStyle: chartColors[index % chartColors.length], hidden: !chart.isDatasetVisible(index), datasetIndex: index })),
|
||||
} },
|
||||
tooltip: { callbacks: {
|
||||
label: context => `${context.dataset.label}: ${typeof context.raw === 'number' ? formatCost(context.raw) : '-'}`,
|
||||
footer: items => `${t('已显示合计', 'Visible subtotal')}: ${formatCost(items.reduce((sum, item) => sum + (typeof item.raw === 'number' ? item.raw : 0), 0))}`,
|
||||
} },
|
||||
},
|
||||
}))
|
||||
const slices = computed(() => providerSlices(props.data.providers, t('未知提供商', 'Unknown provider'), t('其他提供商', 'Other providers')))
|
||||
const providerChart = computed<ChartData<'doughnut'>>(() => ({
|
||||
labels: slices.value.map(slice => slice.label),
|
||||
datasets: [{ data: slices.value.map(slice => slice.value), backgroundColor: slices.value.map((_, index) => chartColors[index % chartColors.length]), borderWidth: 2, borderColor: 'rgba(255,255,255,0.15)' }],
|
||||
}))
|
||||
const providerOptions = computed<ChartOptions<'doughnut'>>(() => ({
|
||||
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { font: { size: 10 }, boxWidth: 10, padding: 10,
|
||||
generateLabels: chart => slices.value.map((slice, index) => ({ text: shorten(slice.label), fillStyle: chartColors[index % chartColors.length], hidden: !chart.getDataVisibility(index), index })),
|
||||
} },
|
||||
tooltip: { callbacks: { label: context => {
|
||||
const value = typeof context.raw === 'number' ? context.raw : 0
|
||||
const total = slices.value.reduce((sum, slice) => sum + slice.value, 0)
|
||||
return `${context.label}: ${formatCost(value)} (${total > 0 ? (value / total * 100).toFixed(1) : '0'}%)`
|
||||
} } },
|
||||
},
|
||||
}))
|
||||
function shorten(value: string) { return value.length > 25 ? `${value.slice(0, 24)}...` : value }
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<section class="min-w-0 space-y-3 border-t pt-5">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('每日统计', 'Daily statistics') }}
|
||||
</h2>
|
||||
<div class="divide-y sm:hidden">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.bucket_start"
|
||||
class="space-y-2 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<RouterLink
|
||||
:to="dailyLink(row.bucket_start)"
|
||||
class="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{{ chartDate(row.bucket_start, range.timezone) }}
|
||||
</RouterLink>
|
||||
<span class="tabular-nums">{{ money(row.billable_amount) }}<span
|
||||
v-if="amountStatus(row.billable_amount, t)"
|
||||
class="ml-1 text-amber-700 dark:text-amber-400"
|
||||
>{{ amountStatus(row.billable_amount, t) }}</span></span>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<div class="flex flex-wrap justify-between gap-x-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('请求', 'Requests') }}
|
||||
</dt><dd class="tabular-nums">
|
||||
{{ count(row.request_count) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-x-2">
|
||||
<dt class="text-muted-foreground">
|
||||
Tokens
|
||||
</dt><dd class="tabular-nums">
|
||||
{{ count(row.total_tokens) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-x-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('响应', 'Response') }}
|
||||
</dt><dd class="tabular-nums">
|
||||
{{ latency(row.latency_ms.avg) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-x-2">
|
||||
<dt class="text-muted-foreground">
|
||||
{{ t('成功率', 'Success rate') }}
|
||||
</dt><dd class="tabular-nums">
|
||||
{{ percent(row.success_rate.value) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="space-y-2 py-3 text-xs">
|
||||
<div class="flex flex-wrap justify-between gap-2 font-medium">
|
||||
<span>{{ t('周期合计', 'Period total') }}</span><span>{{ money(data.summary.billable_amount) }}<span
|
||||
v-if="amountStatus(data.summary.billable_amount, t)"
|
||||
class="ml-1 text-amber-700 dark:text-amber-400"
|
||||
>{{ amountStatus(data.summary.billable_amount, t) }}</span></span>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-between gap-2 text-muted-foreground">
|
||||
<span>{{ t('请求', 'Requests') }} {{ count(data.summary.request_count) }}</span><span>Tokens {{ count(data.summary.total_tokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden overflow-x-auto sm:block">
|
||||
<table class="w-full min-w-[600px] text-left text-xs tabular-nums">
|
||||
<thead class="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 font-medium">
|
||||
{{ t('日期', 'Date') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('请求次数', 'Requests') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
Tokens
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('费用', 'Cost') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('平均响应', 'Avg response') }}
|
||||
</th>
|
||||
<th class="py-3 pl-3 text-right font-medium">
|
||||
{{ t('成功率', 'Success rate') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr v-if="!rows.length">
|
||||
<td
|
||||
colspan="6"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无数据', 'No data') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
:key="row.bucket_start"
|
||||
>
|
||||
<td class="py-3">
|
||||
<RouterLink
|
||||
:to="dailyLink(row.bucket_start)"
|
||||
class="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{{ chartDate(row.bucket_start, range.timezone) }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ count(row.request_count) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ count(row.total_tokens) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ money(row.billable_amount) }}
|
||||
<span
|
||||
v-if="amountStatus(row.billable_amount, t)"
|
||||
class="block text-[10px] text-amber-700 dark:text-amber-400"
|
||||
>{{ amountStatus(row.billable_amount, t) }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ latency(row.latency_ms.avg) }}
|
||||
</td>
|
||||
<td class="py-3 pl-3 text-right">
|
||||
{{ percent(row.success_rate.value) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="border-t bg-muted/30 font-medium">
|
||||
<tr>
|
||||
<td class="py-3">
|
||||
{{ t('周期合计', 'Period total') }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ count(data.summary.request_count) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ count(data.summary.total_tokens) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ money(data.summary.billable_amount) }}
|
||||
<span
|
||||
v-if="amountStatus(data.summary.billable_amount, t)"
|
||||
class="block text-[10px] text-amber-700 dark:text-amber-400"
|
||||
>{{ amountStatus(data.summary.billable_amount, t) }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right">
|
||||
{{ latency(data.summary.latency_ms.avg) }}
|
||||
</td>
|
||||
<td class="py-3 pl-3 text-right">
|
||||
{{ percent(data.summary.success_rate.value) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { OverviewDashboardCharts, OverviewRange } from '@/api/overview'
|
||||
import { count, money, percent } from '../format'
|
||||
import { rangeQuery } from '../query'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { amountStatus } from './amount'
|
||||
import { chartDate, dayRange } from './charts'
|
||||
|
||||
const props = defineProps<{ data: OverviewDashboardCharts; range: OverviewRange }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const rows = computed(() => [...props.data.series].sort((a, b) => b.bucket_start.localeCompare(a.bucket_start)))
|
||||
const latency = (value: number | null) => value == null ? '-' : `${count(value)} ms`
|
||||
function dailyLink(bucket: string) { return { path: '/admin/usage', query: rangeQuery(dayRange(bucket, props.range)) } }
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-2">
|
||||
<Card
|
||||
class="min-w-0 overflow-hidden p-4 sm:p-5"
|
||||
:title="scopeHint"
|
||||
:aria-busy="loading"
|
||||
data-dashboard-activity
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 sm:items-center">
|
||||
<div class="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ t('连续活跃', 'Activity streak') }}
|
||||
</h3>
|
||||
<Skeleton
|
||||
v-if="loading"
|
||||
class="h-8 w-28"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="flex shrink-0 items-baseline gap-1.5"
|
||||
:title="t('连续活跃天数 / 总活跃天数。今天尚无请求时,从昨天向前计算;有请求的日期计为一个活跃日。', 'Consecutive active days / total active days. If today has no requests yet, the streak ends yesterday. Each date with requests counts as one active day.')"
|
||||
>
|
||||
<span class="text-[28px] font-semibold leading-8 tabular-nums text-foreground">{{ count(consecutiveActiveDays) }} / {{ count(activeDays) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t('天', 'days') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 pt-1 text-[10px] text-muted-foreground sm:pt-0">
|
||||
<span class="mr-0.5">{{ t('少', 'Less') }}</span>
|
||||
<span
|
||||
v-for="level in legendLevels"
|
||||
:key="level"
|
||||
class="h-3 w-3 rounded-[4px]"
|
||||
:style="{ backgroundColor: `rgba(var(--color-primary-rgb), ${level})` }"
|
||||
/>
|
||||
<span class="ml-0.5">{{ t('多', 'More') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton
|
||||
v-if="loading"
|
||||
class="mt-4 h-24 w-full"
|
||||
/>
|
||||
<ActivityHeatmap
|
||||
v-else-if="data?.days.length"
|
||||
class="mt-4"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
:compact="true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="mt-4 flex h-24 items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无活跃数据', 'No activity yet') }}
|
||||
</div>
|
||||
</Card>
|
||||
<IntervalTimelineCard
|
||||
:title="t('全站请求间隔(最近24小时)', 'Site-wide request intervals (last 24 hours)')"
|
||||
:is-admin="true"
|
||||
:hours="24"
|
||||
:refresh-interval-ms="0"
|
||||
:data="timelineData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card, Skeleton } from '@/components/ui'
|
||||
import ActivityHeatmap from '@/components/stats/ActivityHeatmap.vue'
|
||||
import IntervalTimelineCard from '@/features/usage/components/IntervalTimelineCard.vue'
|
||||
import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
import type { IntervalTimelineResponse } from '@/api/cache'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count } from '../format'
|
||||
|
||||
defineProps<{ data: ActivityHeatmapData | null; consecutiveActiveDays: number | null; activeDays: number | null; scopeHint: string; loading: boolean; error: boolean; timelineData?: IntervalTimelineResponse | null }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<aside
|
||||
id="announcements-section"
|
||||
class="flex min-h-0 min-w-0 flex-col"
|
||||
:aria-label="text('系统公告', 'System announcements')"
|
||||
>
|
||||
<div class="mb-4 flex h-5 shrink-0 items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
{{ text('系统公告', 'System announcements') }}
|
||||
</h3>
|
||||
<RouterLink
|
||||
:to="authStore.canAccessAdmin ? '/admin/announcements' : '/dashboard/announcements'"
|
||||
class="shrink-0 text-[11px] text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
{{ text('查看全部', 'View all') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<Card class="flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||
<div
|
||||
v-if="displayItems.length"
|
||||
class="min-h-0 max-h-72 flex-1 overflow-y-auto overscroll-contain min-[1440px]:max-h-none"
|
||||
:aria-busy="announcementStore.loading"
|
||||
>
|
||||
<ol class="px-4 py-1">
|
||||
<li
|
||||
v-for="item in displayItems"
|
||||
:key="item.announcement.id"
|
||||
class="relative border-l border-border/60 py-3 pl-4 last:border-transparent"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute -left-[3.5px] top-[18px] h-1.5 w-1.5 rounded-full ring-4 ring-card"
|
||||
:class="item.announcement.is_read ? 'bg-border' : 'bg-primary'"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full min-w-0 text-left transition hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4"
|
||||
:aria-label="`${item.announcement.title}, ${item.announcement.is_read ? text('已读', 'Read') : text('未读', 'Unread')}`"
|
||||
@click="openAnnouncement?.(item.announcement)"
|
||||
>
|
||||
<time
|
||||
:datetime="item.announcement.created_at"
|
||||
class="block text-[10px] leading-4 text-muted-foreground"
|
||||
>{{ formatTime(item.announcement.created_at) }}</time>
|
||||
<span
|
||||
translate="no"
|
||||
class="mt-1 block truncate text-xs font-medium leading-5"
|
||||
:class="item.announcement.is_read ? 'text-muted-foreground' : 'text-foreground'"
|
||||
>{{ item.announcement.title }}</span>
|
||||
<span
|
||||
translate="no"
|
||||
class="mt-0.5 line-clamp-2 text-[11px] leading-[18px] text-muted-foreground [overflow-wrap:anywhere]"
|
||||
>{{ item.summary }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="announcementStore.loading"
|
||||
role="status"
|
||||
class="flex min-h-28 flex-1 items-center justify-center gap-2 px-4 py-6 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ text('加载中', 'Loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex min-h-28 flex-1 flex-col items-center justify-center gap-2 px-4 py-6 text-xs text-muted-foreground"
|
||||
>
|
||||
<p>{{ announcementStore.error ? text('公告暂时无法加载', 'Announcements are unavailable') : text('暂无公告', 'No announcements') }}</p>
|
||||
<button
|
||||
v-if="announcementStore.error"
|
||||
type="button"
|
||||
class="underline underline-offset-4 hover:text-foreground"
|
||||
@click="announcementStore.loadList()"
|
||||
>
|
||||
{{ text('重新加载', 'Reload') }}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { marked } from 'marked'
|
||||
import { Card } from '@/components/ui'
|
||||
import { openAnnouncementKey } from '@/components/common/announcementContext'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
const openAnnouncement = inject(openAnnouncementKey, undefined)
|
||||
const { locale } = useI18n()
|
||||
const text = (zh: string, en: string) => locale.value === 'zh-CN' ? zh : en
|
||||
const displayItems = computed(() => announcementStore.items.map(announcement => {
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = sanitizeMarkdown(marked.parse(announcement.content, { async: false }))
|
||||
return { announcement, summary: (template.content.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 160) }
|
||||
}))
|
||||
|
||||
function formatTime(value: string) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(locale.value, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void announcementStore.loadList()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<section class="space-y-3 border-y py-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<RouterLink
|
||||
to="/admin/health-monitor?window=1h"
|
||||
class="inline-flex items-center gap-1 text-sm font-semibold hover:text-primary"
|
||||
>
|
||||
{{ t('服务健康', 'Service health') }}<ArrowUpRight class="h-3.5 w-3.5" />
|
||||
</RouterLink><span class="text-xs text-muted-foreground">{{ t('全站 API 格式 · 近 1 小时', 'All API formats · Last hour') }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="loading && !data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('加载中', 'Loading') }}
|
||||
</p>
|
||||
<div
|
||||
v-else-if="error"
|
||||
role="alert"
|
||||
class="flex items-center gap-2 text-xs text-destructive"
|
||||
>
|
||||
<span>{{ t('健康数据暂不可用', 'Health data unavailable') }}</span><button
|
||||
class="underline"
|
||||
@click="refresh"
|
||||
>
|
||||
{{ t('重试', 'Retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<dl
|
||||
v-if="data"
|
||||
class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('总体状态', 'Overall status') }}
|
||||
</dt><dd
|
||||
class="mt-1 font-semibold"
|
||||
:class="data.data.status === 'healthy' ? 'text-emerald-700 dark:text-emerald-400' : data.data.status === 'unknown' ? 'text-muted-foreground' : 'text-destructive'"
|
||||
>
|
||||
{{ status }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('服务可用率', 'Service availability') }}
|
||||
</dt><dd class="mt-1 font-semibold tabular-nums">
|
||||
{{ percent(data.data.requests.service_availability.value) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('异常对象', 'Affected objects') }}
|
||||
</dt><dd class="mt-1 font-semibold tabular-nums">
|
||||
{{ count(data.data.degraded_count + data.data.unavailable_count) }} / {{ count(data.data.object_count) }}
|
||||
</dd>
|
||||
</div><div>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('状态未知', 'Unknown status') }}
|
||||
</dt><dd class="mt-1 font-semibold tabular-nums">
|
||||
{{ count(data.data.unknown_count) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('更新', 'Updated') }} {{ timestamp(data.meta.generated_at) }} · {{ data.meta.freshness === 'current' ? t('采集正常', 'Current') : data.meta.freshness === 'stale' ? t('采集滞后', 'Delayed') : t('采集进度未知', 'Collection progress unknown') }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowUpRight } from 'lucide-vue-next'
|
||||
import { getAdminHealthSummary } from '@/api/endpoints/health-v2'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count, percent, timestamp } from '../format'
|
||||
const props = defineProps<{ revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { data, loading, error, refresh } = useOverviewRequest(() => props.revision, signal => getAdminHealthSummary({ kind: 'api_format', window: '1h' }, signal), { scopeKey: () => 'api_format:1h' })
|
||||
const status = computed(() => ({ healthy: t('正常', 'Healthy'), degraded: t('降级', 'Degraded'), unavailable: t('不可用', 'Unavailable'), unknown: t('未知', 'Unknown') })[data.value?.data.status || 'unknown'])
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<section class="space-y-3 border-b pb-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('较前一等长时段', 'Versus previous equal period') }}
|
||||
</h2><span
|
||||
v-if="data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ timestamp(data.meta.range.from, range.timezone) }} - {{ timestamp(data.meta.range.to, range.timezone) }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ t('对比数据暂不可用', 'Comparison unavailable') }}
|
||||
</p><p
|
||||
v-else-if="loading && !data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('加载中', 'Loading') }}
|
||||
</p>
|
||||
<dl
|
||||
v-if="data"
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="item in changes"
|
||||
:key="item.label"
|
||||
>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</dt><dd class="mt-1 text-sm font-semibold tabular-nums">
|
||||
{{ item.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="data"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('对比更新', 'Comparison updated') }} {{ timestamp(data.meta.generated_at, range.timezone) }} · {{ data.meta.coverage.status === 'complete' ? t('数据完整', 'Complete') : t('部分数据', 'Partial data') }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { overviewApi, type OverviewMetrics, type OverviewRange } from '@/api/overview'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { timestamp } from '../format'
|
||||
const props = defineProps<{ current: OverviewMetrics; range: OverviewRange; revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const previousRange = computed(() => ({ from: new Date(Date.parse(props.range.from) - (Date.parse(props.range.to) - Date.parse(props.range.from))).toISOString(), to: props.range.from, timezone: props.range.timezone }))
|
||||
const { data, loading, error } = useOverviewRequest(() => [previousRange.value, props.revision], signal => overviewApi.summary(previousRange.value, signal), { scopeKey: () => JSON.stringify(previousRange.value) })
|
||||
function delta(current: number | null | undefined, previous: number | null | undefined) {
|
||||
if (current == null || previous == null) return '-'
|
||||
if (!previous) return current ? t('前期为零', 'Previous period zero') : '0.0%'
|
||||
const value = (current - previous) / previous * 100
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}%`
|
||||
}
|
||||
const changes = computed(() => {
|
||||
const previous = data.value?.data
|
||||
return [{ label: t('请求数', 'Requests'), value: delta(props.current.request_count, previous?.request_count) }, { label: 'Tokens', value: delta(props.current.total_tokens, previous?.total_tokens) }, { label: t('计费消耗', 'Billable consumption'), value: delta(props.current.billable_amount.value == null ? null : Number(props.current.billable_amount.value), previous?.billable_amount.value == null ? null : Number(previous.billable_amount.value)) }, { label: t('有使用成员', 'Members with usage'), value: delta(props.current.usage_active_users, previous?.usage_active_users) }]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:data-metric="item.key"
|
||||
class="min-w-0 rounded-lg border bg-card p-3 sm:p-5"
|
||||
>
|
||||
<dt class="flex min-h-5 items-start justify-between gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span class="break-words">{{ item.label }}</span>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:class="item.color"
|
||||
/>
|
||||
</dt>
|
||||
<dd class="mt-4 min-h-7 font-semibold tabular-nums">
|
||||
<MetricValue :value="item.value" />
|
||||
</dd>
|
||||
<p
|
||||
v-if="item.note"
|
||||
class="mt-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ item.note }}
|
||||
</p>
|
||||
<div class="mt-3 border-t pt-3 text-xs text-muted-foreground">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1">
|
||||
<dt>{{ item.totalLabel }}</dt>
|
||||
<dd class="break-all font-medium tabular-nums text-foreground">
|
||||
{{ item.totalValue }}
|
||||
</dd>
|
||||
</div>
|
||||
<p
|
||||
v-if="item.totalNote"
|
||||
class="mt-1 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ item.totalNote }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Activity, Coins, Users, Zap } from 'lucide-vue-next'
|
||||
import type { OverviewDashboard } from '@/api/overview'
|
||||
import MetricValue from '../components/MetricValue.vue'
|
||||
import { count, money } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { amountStatus, amountValue } from './amount'
|
||||
|
||||
const props = defineProps<{ data: OverviewDashboard }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const items = computed(() => {
|
||||
const today = props.data.today.data
|
||||
const total = props.data.total.data
|
||||
const cost = (amount: typeof today.billable_amount) => amountValue(amount) === null ? '-' : money(amount)
|
||||
return [
|
||||
{ key: 'requests', label: t('今日请求', 'Today\'s requests'), value: count(today.request_count), totalLabel: t('总请求', 'Total requests'), totalValue: count(total.request_count), icon: Activity, color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ key: 'tokens', label: t('今日 Token', 'Today\'s tokens'), value: count(today.total_tokens), totalLabel: t('总 Token', 'Total tokens'), totalValue: count(total.total_tokens), icon: Zap, color: 'text-violet-600 dark:text-violet-400' },
|
||||
{ key: 'cost', label: t('今日费用', 'Today\'s cost'), value: cost(today.billable_amount), note: amountStatus(today.billable_amount, t), totalLabel: t('总费用', 'Total cost'), totalValue: cost(total.billable_amount), totalNote: amountStatus(total.billable_amount, t), icon: Coins, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
{ key: 'employees', label: t('今日活跃成员', 'Active members today'), value: count(today.usage_active_users), totalLabel: t('启用成员', 'Enabled members'), totalValue: count(today.enabled_users ?? total.enabled_users), icon: Users, color: 'text-amber-600 dark:text-amber-400' },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { OverviewAmount } from '@/api/overview'
|
||||
|
||||
export function amountValue(amount: OverviewAmount | null | undefined): number | null {
|
||||
if (amount?.value == null || amount.status === 'unknown') return null
|
||||
const value = Number(amount.value)
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export function amountStatus(amount: OverviewAmount | null | undefined, t: (zh: string, en: string) => string): string {
|
||||
if (amountValue(amount) === null) return t('金额未知', 'Amount unknown')
|
||||
if (amount?.status === 'known_subtotal') return t('已知小计', 'Known subtotal')
|
||||
if (amount?.status === 'estimated_subtotal') return t('已知范围估算', 'Estimate within known coverage')
|
||||
if (amount?.status === 'estimated') return t('估算', 'Estimated')
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { OverviewBreakdown, OverviewDashboardCharts, OverviewRange } from '@/api/overview'
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
import { zonedInput, zonedInstant } from '../query'
|
||||
import { amountValue } from './amount'
|
||||
|
||||
export const chartColors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#94a3b8']
|
||||
type ModelRow = OverviewDashboardCharts['models'][number]
|
||||
|
||||
export function chartDate(value: string, timezone: string): string {
|
||||
return new Intl.DateTimeFormat(getI18nLocale(), { timeZone: timezone, month: 'short', day: 'numeric' }).format(new Date(value))
|
||||
}
|
||||
|
||||
function knownSum(rows: { billable_amount: OverviewBreakdown['billable_amount'] }[]): number | null {
|
||||
if (!rows.length) return 0
|
||||
const values = rows.map(row => amountValue(row.billable_amount)).filter((value): value is number => value !== null)
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) : null
|
||||
}
|
||||
|
||||
export function modelDatasets(data: OverviewDashboardCharts, unknownLabel: string, otherLabel: string) {
|
||||
const groups = new Map<string | null, ModelRow[]>()
|
||||
for (const row of data.models) {
|
||||
const rows = groups.get(row.id)
|
||||
if (rows) rows.push(row)
|
||||
else groups.set(row.id, [row])
|
||||
}
|
||||
const sorted = [...groups].sort((a, b) => (knownSum(b[1]) ?? -1) - (knownSum(a[1]) ?? -1))
|
||||
const visible = sorted.slice(0, 6).map(([id, rows]) => ({ id, label: rows[0]?.label || unknownLabel, rows }))
|
||||
if (sorted.length > 6) visible.push({ id: '__other__', label: otherLabel, rows: sorted.slice(6).flatMap(([, rows]) => rows) })
|
||||
return visible.map((group, index) => ({
|
||||
label: group.label,
|
||||
data: data.series.map(day => knownSum(group.rows.filter(row => Date.parse(row.bucket_start) === Date.parse(day.bucket_start)))),
|
||||
backgroundColor: chartColors[index % chartColors.length],
|
||||
borderRadius: 2, stack: 'models', barPercentage: 0.6, categoryPercentage: 0.7,
|
||||
}))
|
||||
}
|
||||
|
||||
export function providerSlices(rows: OverviewBreakdown[], unknownLabel: string, otherLabel: string) {
|
||||
const known = rows.map(row => ({ label: row.label || unknownLabel, value: amountValue(row.billable_amount) }))
|
||||
.filter((row): row is { label: string; value: number } => row.value !== null && row.value > 0)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
const visible = known.slice(0, 6)
|
||||
if (known.length > 6) visible.push({ label: otherLabel, value: known.slice(6).reduce((sum, row) => sum + row.value, 0) })
|
||||
return visible
|
||||
}
|
||||
|
||||
export function dayRange(bucket: string, range: OverviewRange): OverviewRange {
|
||||
const date = zonedInput(bucket, range.timezone).slice(0, 10)
|
||||
const nextDate = new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10)
|
||||
const from = localDayStart(date, range.timezone)
|
||||
const to = localDayStart(nextDate, range.timezone)
|
||||
return {
|
||||
from: new Date(Math.max(Date.parse(from), Date.parse(range.from))).toISOString(),
|
||||
to: new Date(Math.min(Date.parse(to), Date.parse(range.to))).toISOString(),
|
||||
timezone: range.timezone,
|
||||
}
|
||||
}
|
||||
|
||||
function localDayStart(date: string, timezone: string): string {
|
||||
const midnight = Date.parse(`${date}T00:00:00Z`)
|
||||
// Some timezone transitions skip midnight; start at the first existing local minute.
|
||||
for (let minute = 0; minute <= 1440; minute++) {
|
||||
const wall = new Date(midnight + minute * 60_000).toISOString().slice(0, 16)
|
||||
const instant = zonedInstant(wall, timezone)
|
||||
if (instant) return instant
|
||||
}
|
||||
throw new Error('Unable to resolve local day boundary')
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { IntervalTimelineResponse } from '@/api/cache'
|
||||
import type { DailyStat, DailyStatsResponse, ModelBreakdown } from '@/api/dashboard'
|
||||
import type { OverviewDashboardSummary } from '@/api/overview'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
const HOUR_MS = 3_600_000
|
||||
const modelNames = ['gpt-5.4', 'claude-sonnet-4', 'gemini-2.5-pro']
|
||||
const providerNames = ['OpenAI', 'Anthropic', 'Google']
|
||||
|
||||
function dateKey(instant: number, timezone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).formatToParts(new Date(instant))
|
||||
const part = (type: string) => parts.find(item => item.type === type)?.value ?? ''
|
||||
return `${part('year')}-${part('month')}-${part('day')}`
|
||||
}
|
||||
|
||||
function shiftDate(date: string, days: number): string {
|
||||
return new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function dayStart(date: string, timezone: string): number {
|
||||
// Locate the actual local date boundary, including midnight DST transitions.
|
||||
const nominal = Date.parse(`${date}T00:00:00Z`)
|
||||
let left = nominal - 36 * HOUR_MS
|
||||
let right = nominal + 36 * HOUR_MS
|
||||
while (left < right) {
|
||||
const middle = Math.floor((left + right) / 2)
|
||||
if (dateKey(middle, timezone) < date) left = middle + 1
|
||||
else right = middle
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
function dailyModels(date: string, today: string): ModelBreakdown[] {
|
||||
const age = Math.round((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${date}T00:00:00Z`)) / DAY_MS)
|
||||
const serial = Math.floor(Date.parse(`${date}T00:00:00Z`) / DAY_MS)
|
||||
const total = age < 0 || age >= 90 ? 0 : 1800 + ((serial % 13 + 13) % 13) * 120
|
||||
const requests = [Math.floor(total * 0.5), Math.floor(total * 0.3)]
|
||||
requests.push(total - requests[0] - requests[1])
|
||||
return modelNames.map((model, index) => ({
|
||||
model, requests: requests[index], tokens: requests[index] * [1100, 1600, 850][index],
|
||||
cost: requests[index] * [3, 5, 2][index] / 100,
|
||||
}))
|
||||
}
|
||||
|
||||
function dailyRow(date: string, models: ModelBreakdown[]): DailyStat {
|
||||
const requests = models.reduce((sum, model) => sum + model.requests, 0)
|
||||
return {
|
||||
date, requests, tokens: models.reduce((sum, model) => sum + model.tokens, 0),
|
||||
cost: Number(models.reduce((sum, model) => sum + model.cost, 0).toFixed(2)),
|
||||
avg_response_time: requests ? 1.52 : 0, unique_models: requests ? models.length : 0,
|
||||
unique_providers: requests ? providerNames.length : 0, model_breakdown: models,
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthetic display data only; no account, usage, or billing writes. */
|
||||
export function createDashboardDemo(timezone: string): OverviewDashboardSummary {
|
||||
const now = Date.now()
|
||||
const today = dateKey(now, timezone)
|
||||
const todayFrom = dayStart(today, timezone)
|
||||
const activityDays = Array.from({ length: 365 }, (_, index) => {
|
||||
const date = shiftDate(today, index - 364)
|
||||
return { date, requests: dailyModels(date, today).reduce((sum, model) => sum + model.requests, 0) }
|
||||
})
|
||||
const days = activityDays.slice(-90).map(day => dailyRow(day.date, dailyModels(day.date, today)))
|
||||
const current = days[days.length - 1]
|
||||
const input = Math.round(current.tokens * 0.75)
|
||||
const stream = Math.round(current.requests * 0.91)
|
||||
const amount = (value: number) => ({ value: value.toFixed(8), currency: 'USD', basis: 'billable', status: 'known' })
|
||||
const observedFrom = Math.max(todayFrom, now - 2 * HOUR_MS)
|
||||
return {
|
||||
stats_since: new Date(dayStart(shiftDate(today, -89), timezone)).toISOString(),
|
||||
generated_at: new Date(now).toISOString(), timezone,
|
||||
today_from: new Date(todayFrom).toISOString(), window_seconds: Math.floor((now - todayFrom) / 1000),
|
||||
today: {
|
||||
request_count: current.requests, input_tokens: input, output_tokens: current.tokens - input,
|
||||
total_tokens: current.tokens, billable_amount: amount(current.cost), active_users: 24,
|
||||
cache_read_tokens: Math.round(input * 0.63), cache_creation_tokens: Math.round(input * 0.12), cache_input_tokens: input,
|
||||
avg_first_byte_ms: 247, avg_response_ms: 1520, stream_requests: stream, standard_requests: current.requests - stream,
|
||||
},
|
||||
total: {
|
||||
request_count: days.reduce((sum, day) => sum + day.requests, 0),
|
||||
total_tokens: days.reduce((sum, day) => sum + day.tokens, 0),
|
||||
billable_amount: amount(days.reduce((sum, day) => sum + day.cost, 0)),
|
||||
cache_read_tokens: Math.round(days.reduce((sum, day) => sum + Math.round(day.tokens * 0.75), 0) * 0.58),
|
||||
cache_input_tokens: days.reduce((sum, day) => sum + Math.round(day.tokens * 0.75), 0),
|
||||
},
|
||||
users: { total: 63, created_today: 4, deleted_today: 1 }, consecutive_active_days: 90, active_days: 90, activity_days: activityDays,
|
||||
concurrency: {
|
||||
avg: 6.84, peak: 23, observed_from: new Date(observedFrom).toISOString(), observed_through: new Date(now).toISOString(),
|
||||
scope: 'node', coverage: observedFrom > todayFrom ? 'partial' : 'complete',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createDashboardTimelineDemo(): IntervalTimelineResponse {
|
||||
const now = Date.now()
|
||||
const userIds = ['demo-dev', 'demo-production', 'demo-sandbox']
|
||||
const points = Array.from({ length: 108 }, (_, index) => ({
|
||||
x: new Date(now - DAY_MS + (index + 1) * DAY_MS / 109).toISOString(),
|
||||
y: Number((1.5 + (index * 17 % 240) + (index % 3) * 0.4).toFixed(1)),
|
||||
user_id: userIds[index % userIds.length], model: modelNames[index % modelNames.length],
|
||||
}))
|
||||
return {
|
||||
analysis_period_hours: 24, total_points: points.length, points,
|
||||
users: { 'demo-dev': '开发团队', 'demo-production': '生产应用', 'demo-sandbox': '测试环境' }, models: [...modelNames],
|
||||
}
|
||||
}
|
||||
|
||||
export function createDashboardDailyDemo(params: DateRangeParams): DailyStatsResponse {
|
||||
const timezone = params.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
const now = Date.now()
|
||||
const today = dateKey(now, timezone)
|
||||
const periodDays = params.preset === 'last90days' ? 90 : params.preset === 'last30days' ? 30
|
||||
: params.preset === 'today' || params.preset === 'yesterday' ? 1 : 7
|
||||
const preciseFrom = params.from ? Date.parse(params.from) : NaN
|
||||
const preciseTo = params.to ? Date.parse(params.to) : NaN
|
||||
const precise = Number.isFinite(preciseFrom) && Number.isFinite(preciseTo) && preciseFrom < preciseTo
|
||||
const validDate = (value: string | undefined) => value && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : undefined
|
||||
let endDate = precise ? dateKey(preciseTo - 1, timezone)
|
||||
: validDate(params.end_date) || shiftDate(today, params.preset === 'yesterday' ? -1 : 0)
|
||||
let startDate = precise ? dateKey(preciseFrom, timezone)
|
||||
: validDate(params.start_date) || shiftDate(endDate, 1 - periodDays)
|
||||
if (startDate > endDate) [startDate, endDate] = [endDate, startDate]
|
||||
startDate = startDate < shiftDate(endDate, -365) ? shiftDate(endDate, -365) : startDate
|
||||
const days = Math.round((Date.parse(endDate) - Date.parse(startDate)) / DAY_MS) + 1
|
||||
const rows: DailyStat[] = []
|
||||
for (let index = 0; index < days; index += 1) {
|
||||
const date = shiftDate(startDate, index)
|
||||
const models = dailyModels(date, today)
|
||||
if (params.granularity !== 'hour') {
|
||||
rows.push(dailyRow(date, models))
|
||||
continue
|
||||
}
|
||||
const from = dayStart(date, timezone)
|
||||
const to = Math.min(dayStart(shiftDate(date, 1), timezone), now + 1)
|
||||
const hours = Math.max(0, Math.ceil((to - from) / HOUR_MS))
|
||||
for (let hour = 0; hour < hours; hour += 1) {
|
||||
const bucket = from + hour * HOUR_MS
|
||||
if (precise && (bucket >= preciseTo || bucket + HOUR_MS <= preciseFrom)) continue
|
||||
const buckets = models.map(model => {
|
||||
const requests = Math.floor(model.requests / hours) + (hour < model.requests % hours ? 1 : 0)
|
||||
const share = model.requests ? requests / model.requests : 0
|
||||
return { ...model, requests, tokens: Math.round(model.tokens * share), cost: Number((model.cost * share).toFixed(2)) }
|
||||
})
|
||||
rows.push(dailyRow(new Date(bucket).toISOString(), buckets))
|
||||
}
|
||||
}
|
||||
const modelSummary = modelNames.map((model, index) => {
|
||||
const entries = rows.map(row => row.model_breakdown[index])
|
||||
const requests = entries.reduce((sum, entry) => sum + entry.requests, 0)
|
||||
const tokens = entries.reduce((sum, entry) => sum + entry.tokens, 0)
|
||||
const cost = Number(entries.reduce((sum, entry) => sum + entry.cost, 0).toFixed(2))
|
||||
return { model, requests, tokens, cost, avg_response_time: requests ? 1.52 : 0, cost_per_request: requests ? cost / requests : 0, tokens_per_request: requests ? tokens / requests : 0 }
|
||||
})
|
||||
return {
|
||||
daily_stats: rows, model_summary: modelSummary,
|
||||
provider_summary: modelSummary.map((model, index) => ({ provider: providerNames[index], requests: model.requests, tokens: model.tokens, cost: model.cost })),
|
||||
period: { start_date: startDate, end_date: endDate, days },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { OverviewAmount } from '@/api/overview'
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
|
||||
export function count(value: number | null | undefined): string {
|
||||
return value == null ? '-' : new Intl.NumberFormat(getI18nLocale(), { maximumFractionDigits: 2 }).format(value)
|
||||
}
|
||||
|
||||
export function attributionLabel(kind: string | null | undefined): string {
|
||||
const zh = getI18nLocale() === 'zh-CN'
|
||||
if (kind === 'employee') return zh ? '成员账号' : 'Member account'
|
||||
if (kind === 'standalone') return zh ? '独立余额 Key' : 'Standalone balance key'
|
||||
return !kind || kind === 'unknown' ? (zh ? '未知' : 'Unknown') : kind
|
||||
}
|
||||
|
||||
export function money(amount: OverviewAmount | null | undefined): string {
|
||||
if (amount?.value == null) return '-'
|
||||
const number = Number(amount.value)
|
||||
if (!Number.isFinite(number)) return '-'
|
||||
return new Intl.NumberFormat(getI18nLocale(), { style: 'currency', currency: amount.currency || 'USD', currencyDisplay: 'narrowSymbol', minimumFractionDigits: 2, maximumFractionDigits: 6 }).format(number)
|
||||
}
|
||||
|
||||
export function percent(value: number | null | undefined): string {
|
||||
return value == null ? '-' : `${(value * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function timestamp(value: string | null | undefined, timezone?: string): string {
|
||||
return !value ? '-' : new Intl.DateTimeFormat(getI18nLocale(), { dateStyle: 'short', timeStyle: 'short', timeZone: timezone }).format(new Date(value))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
export function useOverviewI18n() {
|
||||
const { locale } = useI18n()
|
||||
return { t: (zh: string, en: string) => locale.value === 'zh-CN' ? zh : en }
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ resources ? t('资源诊断', 'Resource diagnostics') : t('实时运行', 'Live runtime') }}
|
||||
</h2><p class="min-w-0 break-words text-xs text-muted-foreground">
|
||||
{{ snapshot.scope.kind === 'cluster' ? t('已知节点集合', 'Known nodes') : t('当前节点', 'Current node') }} · {{ snapshot.node_id || t('节点标识未知', 'Unknown node ID') }} · {{ timestamp(snapshot.observed_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="snapshot.unavailable_sections.length"
|
||||
class="break-words rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{{ t('未采集', 'Unavailable') }}: {{ snapshot.unavailable_sections.join(', ') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="!metrics"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('暂无资源采样', 'No resource samples') }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<Card
|
||||
v-for="group in groups"
|
||||
:key="group.title"
|
||||
class="min-w-0 p-4 sm:p-5"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ group.title }}
|
||||
</h3>
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-muted/30 text-muted-foreground">
|
||||
<component
|
||||
:is="group.icon"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div><dl class="grid grid-cols-2 gap-x-5 gap-y-5">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<dt class="min-h-8 break-words text-xs leading-4 text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</dt><dd class="mt-1 min-w-0 font-semibold leading-8 tabular-nums">
|
||||
<MetricValue
|
||||
:value="item.value"
|
||||
:max-font-size="24"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
<Card
|
||||
v-if="!resources && snapshot.resilience"
|
||||
class="min-w-0 space-y-4 p-4 sm:p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold">
|
||||
<ShieldCheck
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('服务韧性', 'Resilience') }}
|
||||
</h3><RouterLink
|
||||
:to="link('/admin/health-monitor')"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ t('健康监控', 'Health monitor') }}
|
||||
</RouterLink>
|
||||
</div><dl class="grid grid-cols-2 gap-5">
|
||||
<div class="min-w-0">
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('已打开熔断器', 'Open circuits') }}
|
||||
</dt><dd class="mt-2 font-semibold tabular-nums">
|
||||
<MetricValue
|
||||
:value="count(snapshot.resilience.error_statistics.open_circuit_breakers)"
|
||||
:max-font-size="24"
|
||||
/>
|
||||
</dd>
|
||||
</div><div class="min-w-0">
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ t('降级凭据', 'Degraded credentials') }}
|
||||
</dt><dd class="mt-2 font-semibold tabular-nums">
|
||||
<MetricValue
|
||||
:value="count(snapshot.resilience.error_statistics.degraded_keys)"
|
||||
:max-font-size="24"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl><ul
|
||||
v-if="snapshot.resilience.recent_errors.length"
|
||||
class="divide-y border-t text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="item in snapshot.resilience.recent_errors.slice(0, 8)"
|
||||
:key="item.error_id"
|
||||
class="flex flex-wrap items-start justify-between gap-2 py-3"
|
||||
>
|
||||
<span class="min-w-0 break-all">{{ item.error_type }} · {{ item.context.provider_name || item.context.model || item.operation }}</span><RouterLink
|
||||
v-if="item.context.request_id"
|
||||
:to="link('/admin/usage', { request_id: item.context.request_id })"
|
||||
class="min-w-0 break-all font-mono text-primary hover:underline"
|
||||
>
|
||||
{{ item.context.request_id }}
|
||||
</RouterLink><span class="text-muted-foreground">{{ timestamp(item.timestamp) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="resources && metrics.upstreamTargets.rows.length"
|
||||
class="min-w-0 space-y-4 p-4 sm:p-5"
|
||||
>
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold">
|
||||
<Network
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('上游准入', 'Upstream admission') }}
|
||||
</h3><div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[420px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-xs text-muted-foreground">
|
||||
<th class="py-2 text-left">
|
||||
{{ t('目标', 'Target') }}
|
||||
</th><th class="px-3 text-right">
|
||||
{{ t('处理中', 'In flight') }}
|
||||
</th><th class="px-3 text-right">
|
||||
{{ t('可用许可', 'Available permits') }}
|
||||
</th><th class="px-3 text-right">
|
||||
{{ t('拒绝累计', 'Rejected total') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead><tbody>
|
||||
<tr
|
||||
v-for="row in metrics.upstreamTargets.rows"
|
||||
:key="row.target"
|
||||
class="border-b"
|
||||
>
|
||||
<td class="max-w-64 break-words py-2">
|
||||
{{ row.target }}
|
||||
</td><td class="px-3 text-right">
|
||||
{{ count(row.inFlight) }}
|
||||
</td><td class="px-3 text-right">
|
||||
{{ count(row.availablePermits) }}
|
||||
</td><td class="px-3 text-right">
|
||||
{{ count(row.rejectedTotal) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Activity, Cpu, Database, Layers, ListChecks, ListTodo, Network, Radio, ShieldCheck } from 'lucide-vue-next'
|
||||
import { Card } from '@/components/ui'
|
||||
import { buildGatewayMetricsSummary } from '@/api/monitoring'
|
||||
import type { OverviewLive } from '@/api/overview'
|
||||
import MetricValue from '../components/MetricValue.vue'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count, timestamp } from '../format'
|
||||
const props = defineProps<{ snapshot: OverviewLive; resources?: boolean }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { link } = useOverviewQuery()
|
||||
const metrics = computed(() => props.snapshot.metrics || (props.snapshot.metrics_text ? buildGatewayMetricsSummary(props.snapshot.metrics_text) : null))
|
||||
function bytes(value: number | null | undefined) { return value == null ? '-' : `${count(value / 1024 / 1024)} MiB` }
|
||||
function basis(value: number | null | undefined) { return value == null ? '-' : `${count(value / 100)}%` }
|
||||
const groups = computed(() => {
|
||||
const m = metrics.value
|
||||
if (!m) return []
|
||||
const runtime = [
|
||||
{ title: t('请求准入', 'Request admission'), icon: Activity, items: [{ label: t('本机处理中', 'Node in flight'), value: count(m.local.inFlight) }, { label: t('本机可用许可', 'Node available permits'), value: count(m.local.availablePermits) }, { label: t('全局处理中', 'Global in flight'), value: count(m.distributed.inFlight) }, { label: t('全局可用许可', 'Global available permits'), value: count(m.distributed.availablePermits) }] },
|
||||
{ title: t('代理通道', 'Proxy tunnels'), icon: Network, items: [{ label: t('连接数', 'Connections'), value: count(m.tunnel.proxyConnections) }, { label: t('活跃流', 'Active streams'), value: count(m.tunnel.activeStreams) }, { label: t('队列深度', 'Queue depth'), value: count(m.tunnel.outboundQueueDepthTotal) }, { label: t('队列容量', 'Queue capacity'), value: count(m.tunnel.outboundQueueCapacityTotal) }] },
|
||||
{ title: t('用量写入', 'Usage writes'), icon: ListTodo, items: [{ label: t('待确认', 'Awaiting acknowledgement'), value: count(m.usageQueue.groupPending) }, { label: t('待消费', 'Awaiting delivery'), value: count(m.usageQueue.groupLag) }, { label: t('死信', 'Dead letters'), value: count(m.usageQueue.dlqLength) }, { label: t('最旧待处理 (ms)', 'Oldest pending (ms)'), value: count(m.usageQueue.oldestPendingIdleMs) }] },
|
||||
]
|
||||
if (!props.resources) return runtime
|
||||
return [
|
||||
{ title: t('进程与主机', 'Process and host'), icon: Cpu, items: [{ label: t('进程 CPU', 'Process CPU'), value: basis(m.process.processCpuUsageBasisPoints) }, { label: t('主机 CPU', 'Host CPU'), value: basis(m.process.systemCpuUsageBasisPoints) }, { label: t('进程内存', 'Process memory'), value: bytes(m.process.processMemoryBytes) }, { label: t('主机内存', 'Host memory'), value: basis(m.process.systemMemoryUsageBasisPoints) }, { label: t('打开文件', 'Open file descriptors'), value: count(m.process.openFds) }] },
|
||||
{ title: 'PostgreSQL', icon: Database, items: [{ label: t('连接池占用', 'Pool checked out'), value: `${count(m.databasePool.checkedOut)} / ${count(m.databasePool.max)}` }, { label: t('数据库等待', 'Database waits'), value: count(m.postgres.waitingConnections) }, { label: t('等待锁', 'Lock waits'), value: count(m.postgres.lockWaitingConnections) }, { label: t('最长查询 (ms)', 'Oldest query (ms)'), value: count(m.postgres.oldestActiveQueryAgeMs) }, { label: t('块缓存命中', 'Block cache hit'), value: basis(m.postgres.blockCacheHitRateBasisPoints) }] },
|
||||
{ title: 'Redis', icon: Radio, items: [{ label: t('已用内存', 'Used memory'), value: bytes(m.redisRuntime.usedMemoryBytes) }, { label: t('阻塞客户端', 'Blocked clients'), value: count(m.redisRuntime.blockedClients) }, { label: t('命中率', 'Hit rate'), value: basis(m.redisRuntime.keyspaceHitRateBasisPoints) }, { label: t('操作 / 秒', 'Operations / second'), value: count(m.redisRuntime.instantaneousOpsPerSec) }, { label: t('淘汰累计', 'Evictions total'), value: count(m.redisRuntime.evictedKeysTotal) }] },
|
||||
{ title: t('后台任务与调度', 'Tasks and scheduling'), icon: Layers, items: [{ label: t('活跃任务', 'Active tasks'), value: count(m.backgroundTasks.active) }, { label: t('异常退出累计', 'Unexpected exits total'), value: count(m.backgroundTasks.unexpectedExitsTotal) }, { label: t('异步任务', 'Async tasks'), value: count(m.tokioRuntime.aliveTasks) }, { label: t('调度队列', 'Scheduling queue'), value: count(m.tokioRuntime.globalQueueDepth) }] },
|
||||
{ title: t('审计计数', 'Audit counters'), icon: ListChecks, items: [{ label: t('待刷新', 'Pending flush'), value: count(m.usageCounter.pendingRows) }, { label: t('最旧待处理 (s)', 'Oldest pending (s)'), value: count(m.usageCounter.oldestPendingAgeSeconds) }, { label: t('失败批次累计', 'Failed batches total'), value: count(m.usageCounter.flushFailedBatchesTotal) }, { label: t('候选队列', 'Candidate queue'), value: count(m.requestCandidateQueue.depth) }] },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Card
|
||||
class="min-w-0 overflow-hidden p-3 sm:p-4"
|
||||
:data-operations-metric="label"
|
||||
>
|
||||
<div class="flex h-5 min-w-0 items-center justify-between gap-2">
|
||||
<p
|
||||
class="truncate text-xs font-semibold text-muted-foreground"
|
||||
:title="label"
|
||||
>
|
||||
{{ label }}
|
||||
</p>
|
||||
<component
|
||||
:is="icon"
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex h-9 min-w-0 items-center">
|
||||
<MetricValue
|
||||
:value="value"
|
||||
:max-font-size="30"
|
||||
class="w-full font-semibold leading-none tabular-nums text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="note"
|
||||
class="mt-1 truncate text-xs leading-5 text-muted-foreground"
|
||||
:title="note"
|
||||
>
|
||||
{{ note }}
|
||||
</p>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { Card } from '@/components/ui'
|
||||
import MetricValue from '../components/MetricValue.vue'
|
||||
|
||||
defineProps<{ label: string; value: string; icon: Component; note?: string }>()
|
||||
</script>
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<Card
|
||||
class="min-w-0 overflow-hidden"
|
||||
:data-performance-analysis="dimension"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-4 pt-4 sm:px-5">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="rows !== null"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ count(rows.length) }} {{ dimension === 'provider' ? t('个提供商', 'providers') : t('个模型', 'models') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-4 my-4 grid grid-cols-2 divide-x rounded-xl bg-muted/40 sm:mx-5">
|
||||
<div
|
||||
v-for="insight in insightCards"
|
||||
:key="insight.key"
|
||||
class="min-w-0 px-3 py-3"
|
||||
:data-analysis-insight="insight.key"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ insight.label }}
|
||||
<span v-if="insight.value.partial"> · {{ t('已知数据', 'Known data') }}</span>
|
||||
</p>
|
||||
<p
|
||||
class="mt-1 truncate text-sm font-semibold"
|
||||
:title="insight.value.row?.label"
|
||||
>
|
||||
{{ insight.value.row?.label || insightState(insight.value.state) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs tabular-nums text-muted-foreground">
|
||||
<template v-if="insight.value.row">
|
||||
<template v-if="insight.key === 'failures'">
|
||||
{{ count(insight.value.row.failedCount) }} {{ t('次', 'failures') }} ·
|
||||
</template>
|
||||
{{ insight.key === 'traffic' ? t('占总请求', 'Of requests') : t('占总失败', 'Of failures') }} {{ percentage(insight.value.share) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('所选时段', 'Selected range') }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[400px] overflow-auto px-4 pb-2 sm:px-5">
|
||||
<table class="w-full min-w-[660px] table-fixed text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-card text-xs text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="w-[22%] py-3 pr-3 text-left font-medium">
|
||||
{{ dimension === 'provider' ? t('提供商', 'Provider') : t('模型', 'Model') }}
|
||||
</th>
|
||||
<th class="w-[16%] px-2 py-3 text-right font-medium">
|
||||
{{ t('请求量', 'Requests') }}
|
||||
</th>
|
||||
<th class="w-[16%] px-2 py-3 text-right font-medium">
|
||||
{{ t('成功率', 'Success rate') }}
|
||||
</th>
|
||||
<th
|
||||
class="w-[10%] px-2 py-3 text-right font-medium"
|
||||
:title="t('当前节点正在执行的 HTTP 与 Responses WebSocket 请求,流式请求计至结束', 'HTTP and Responses WebSocket requests executing on this node; streams count until they end')"
|
||||
>
|
||||
{{ t('当前并发', 'Concurrency') }}
|
||||
<span class="block text-[10px] font-normal">{{ t('当前节点', 'Current node') }}</span>
|
||||
</th>
|
||||
<th
|
||||
class="w-[10%] px-2 py-3 text-right font-medium"
|
||||
:title="rpmWindowTitle"
|
||||
>
|
||||
RPM
|
||||
<span class="block text-[10px] font-normal">{{ t('近 60 秒', 'Last 60s') }}</span>
|
||||
</th>
|
||||
<th class="w-[13%] px-2 py-3 text-right font-medium">
|
||||
{{ t('平均首字节', 'Avg first byte') }}
|
||||
</th>
|
||||
<th class="w-[13%] py-3 pl-2 text-right font-medium">
|
||||
{{ t('输出速度', 'Output speed') }}
|
||||
<span class="block text-[10px] font-normal">Tokens/s</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr
|
||||
v-for="row in rankedRows"
|
||||
:key="`${row.id ?? ''}:${row.label}`"
|
||||
:data-analysis-row="row.id ?? row.label"
|
||||
>
|
||||
<td class="break-words py-3 pr-3 [overflow-wrap:anywhere]">
|
||||
<RouterLink
|
||||
v-if="row.id !== null"
|
||||
:to="records(row.id)"
|
||||
:data-analysis-entity-link="row.id"
|
||||
class="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{{ row.label }}
|
||||
</RouterLink>
|
||||
<span v-else>{{ row.label }}</span>
|
||||
</td>
|
||||
<td class="px-2 py-3 text-right tabular-nums">
|
||||
{{ count(row.requestCount) }}
|
||||
<div class="mt-1 flex items-center justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
<span class="h-1 w-9 overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
class="block h-full rounded-full bg-primary/45"
|
||||
:style="{ width: `${analysisShare(row.requestCount, totalRequests) ?? 0}%` }"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ percentage(analysisShare(row.requestCount, totalRequests)) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-3 text-right tabular-nums">
|
||||
<span data-analysis-success-rate>{{ percentage(row.successRate) }}</span>
|
||||
<div
|
||||
class="mt-1 text-[11px]"
|
||||
:class="(measuredCount(row.failedCount) ?? 0) > 0 ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground'"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="row.id !== null && measuredCount(row.failedCount) !== null"
|
||||
:to="records(row.id, true)"
|
||||
:data-analysis-failure-link="row.id"
|
||||
class="hover:underline"
|
||||
:aria-label="`${row.label} ${t('失败请求', 'failed requests')}`"
|
||||
>
|
||||
{{ t('失败', 'Failed') }} {{ count(row.failedCount) }}
|
||||
</RouterLink>
|
||||
<span v-else>{{ t('失败', 'Failed') }} {{ count(row.failedCount) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="px-2 py-3 text-right tabular-nums"
|
||||
data-analysis-concurrency
|
||||
>
|
||||
{{ count(row.concurrency ?? null) }}
|
||||
</td>
|
||||
<td
|
||||
class="px-2 py-3 text-right tabular-nums"
|
||||
data-analysis-rpm
|
||||
:title="rpmWindowTitle"
|
||||
>
|
||||
{{ count(row.rpm ?? null) }}
|
||||
</td>
|
||||
<td class="px-2 py-3 text-right tabular-nums">
|
||||
{{ duration(row.firstByteMs) }}
|
||||
</td>
|
||||
<td class="py-3 pl-2 text-right tabular-nums">
|
||||
{{ count(row.outputTps) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rankedRows.length">
|
||||
<td
|
||||
colspan="7"
|
||||
class="py-10 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ rows === null ? t('暂未取得分析数据', 'Analysis data unavailable') : totalRequests === 0 ? t('所选时段暂无请求', 'No requests in this range') : t('所选时段暂无分组数据', 'No grouped data in this range') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Card } from '@/components/ui'
|
||||
import { count as formatCount } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { analysisShare, analyzeRows, measuredCount, rankAnalysisRows, type AnalysisInsight, type AnalysisRow } from './analysisHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
dimension: 'provider' | 'model'
|
||||
rows: AnalysisRow[] | null
|
||||
totalRequests: number | null
|
||||
totalFailures: number | null
|
||||
liveWindowSeconds?: number
|
||||
}>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { link } = useOverviewQuery('today')
|
||||
const rpmWindowTitle = computed(() => props.liveWindowSeconds !== undefined && props.liveWindowSeconds < 60
|
||||
? t(`当前节点启动后已观测 ${formatCount(props.liveWindowSeconds)} 秒,按实际请求计数`, `Current node: ${formatCount(props.liveWindowSeconds)} seconds observed since startup; actual request count`)
|
||||
: t('当前节点最近 60 秒进入上游执行的 HTTP 与 Responses WebSocket 请求数,同一请求重试去重', 'HTTP and Responses WebSocket requests entering upstream execution on this node in the last 60 seconds; retries deduplicated'))
|
||||
const rankedRows = computed(() => rankAnalysisRows(props.rows ?? []))
|
||||
const insights = computed(() => analyzeRows(props.rows, props.totalRequests, props.totalFailures))
|
||||
const insightCards = computed(() => [
|
||||
{ key: 'traffic', label: t('流量最多', 'Most traffic'), value: insights.value.traffic },
|
||||
{ key: 'failures', label: t('失败最多', 'Most failures'), value: insights.value.failures },
|
||||
])
|
||||
|
||||
function records(id: string, failed = false) {
|
||||
return link('/admin/usage', { [props.dimension === 'provider' ? 'provider_id' : 'model']: id, ...(failed ? { status: 'failed' } : {}) })
|
||||
}
|
||||
function count(value: number | null) {
|
||||
const measured = measuredCount(value)
|
||||
return formatCount(measured)
|
||||
}
|
||||
function percentage(value: number | null) {
|
||||
const measured = measuredCount(value)
|
||||
return measured === null || measured > 100 ? '-' : `${count(measured)}%`
|
||||
}
|
||||
function duration(value: number | null) {
|
||||
const measured = measuredCount(value)
|
||||
if (measured === null) return '-'
|
||||
return measured >= 1000 ? `${count(measured / 1000)} s` : `${count(measured)} ms`
|
||||
}
|
||||
function insightState(state: AnalysisInsight['state']) {
|
||||
if (state === 'empty') return t('暂无请求', 'No requests')
|
||||
if (state === 'none') return t('无失败请求', 'No failed requests')
|
||||
return t('暂无数据', 'Unavailable')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-operations-details
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('请求分析', 'Request analysis') }}
|
||||
</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('所选时段 · 失败请求多的优先展示', 'Selected range · Most failures first') }}
|
||||
</p>
|
||||
</div>
|
||||
<OverviewStatus
|
||||
:loading="loading && !data"
|
||||
:error="error"
|
||||
@retry="refresh"
|
||||
/>
|
||||
<div
|
||||
v-if="data"
|
||||
class="grid grid-cols-1 items-start gap-4 min-[1920px]:grid-cols-2"
|
||||
>
|
||||
<PerformanceAnalysisCard
|
||||
:title="t('提供商分析', 'Provider analysis')"
|
||||
dimension="provider"
|
||||
:rows="providerRows"
|
||||
:total-requests="data.data.summary.request_count"
|
||||
:total-failures="data.data.summary.failed_request_count ?? null"
|
||||
:live-window-seconds="activity?.observed_window_seconds"
|
||||
/>
|
||||
<PerformanceAnalysisCard
|
||||
:title="t('模型分析', 'Model analysis')"
|
||||
dimension="model"
|
||||
:rows="modelRows"
|
||||
:total-requests="data.data.summary.request_count"
|
||||
:total-failures="data.data.summary.failed_request_count ?? null"
|
||||
:live-window-seconds="activity?.observed_window_seconds"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-if="data"
|
||||
class="space-y-4"
|
||||
data-operations-charts
|
||||
>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('趋势与分布', 'Trends and distribution') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card class="min-w-0 space-y-3 p-4 sm:p-5">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('请求与失败趋势', 'Request and failure trend') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="data.data.timeseries.length"
|
||||
class="h-64"
|
||||
>
|
||||
<LineChart :data="requestChart" />
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="flex h-64 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无请求', 'No requests in this range') }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="min-w-0 space-y-3 p-4 sm:p-5">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('响应时间趋势', 'Response latency trend') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="data.data.timeseries.length"
|
||||
class="h-64"
|
||||
>
|
||||
<LineChart :data="latencyChart" />
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="flex h-64 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无时延样本', 'No latency samples in this range') }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="min-w-0 space-y-3 p-4 sm:p-5 lg:col-span-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('失败原因分布', 'Failure reasons') }}
|
||||
</h3>
|
||||
<RouterLink
|
||||
:to="link('/admin/usage', { status: 'failed' })"
|
||||
class="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ t('失败请求', 'Failed requests') }}<ArrowUpRight class="h-3.5 w-3.5" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div
|
||||
v-if="data.data.errors.length"
|
||||
class="min-w-0"
|
||||
:style="{ height: `${Math.max(180, Math.min(400, data.data.errors.length * 36))}px` }"
|
||||
>
|
||||
<BarChart
|
||||
:data="failureChart"
|
||||
:options="failureChartOptions"
|
||||
:stacked="false"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="flex h-32 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围未记录失败请求', 'No failed requests recorded in this range') }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
<details
|
||||
class="rounded-2xl border bg-card"
|
||||
data-operations-diagnostics
|
||||
>
|
||||
<summary class="cursor-pointer px-4 py-3 text-sm font-medium sm:px-5">
|
||||
{{ t('技术诊断', 'Technical diagnostics') }}
|
||||
</summary>
|
||||
<div class="space-y-6 border-t p-4 sm:p-5">
|
||||
<section
|
||||
v-if="data"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('性能诊断', 'Performance diagnostics') }}
|
||||
</h3>
|
||||
<Badge variant="outline">
|
||||
{{ t('所选时段', 'Selected range') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<OverviewMetrics
|
||||
:metrics="data.data.summary"
|
||||
mode="performance"
|
||||
/>
|
||||
<OverviewStatus :meta="data.meta" />
|
||||
</section>
|
||||
<slot name="diagnostics" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { ChartOptions } from 'chart.js'
|
||||
import { ArrowUpRight } from 'lucide-vue-next'
|
||||
import { Badge, Card } from '@/components/ui'
|
||||
import BarChart from '@/components/charts/BarChart.vue'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import { overviewApi, type OverviewExecutionActivity, type OverviewQuery } from '@/api/overview'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import OverviewMetrics from '../components/OverviewMetrics.vue'
|
||||
import PerformanceAnalysisCard from './PerformanceAnalysisCard.vue'
|
||||
import { mergeLiveAnalysis, type AnalysisRow } from './analysisHelpers'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { timestamp } from '../format'
|
||||
|
||||
const props = defineProps<{ revision: number; activity?: OverviewExecutionActivity | null }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { range, relativePreset, link } = useOverviewQuery('today')
|
||||
// This overview always analyses all requests. Old diagnostic filters in links
|
||||
// must not silently hide providers or models from the automatic reports.
|
||||
const analysisQuery = computed<OverviewQuery>(() => ({
|
||||
...range.value,
|
||||
granularity: Date.parse(range.value.to) - Date.parse(range.value.from) <= 48 * 3_600_000 ? 'hour' : 'day',
|
||||
}))
|
||||
const scopeKey = computed(() => JSON.stringify(relativePreset.value
|
||||
? { timezone: range.value.timezone, granularity: analysisQuery.value.granularity, preset: relativePreset.value }
|
||||
: analysisQuery.value))
|
||||
const { data, loading, error, refresh } = useOverviewRequest(
|
||||
() => JSON.stringify([analysisQuery.value, props.revision]),
|
||||
signal => overviewApi.performance(analysisQuery.value, signal),
|
||||
{ scopeKey },
|
||||
)
|
||||
const historicalProviders = computed<AnalysisRow[] | null>(() => data.value?.data.providers?.providers.map(row => ({
|
||||
id: row.provider_id || null,
|
||||
label: row.provider || t('未归属提供商', 'Unattributed provider'),
|
||||
requestCount: row.request_count,
|
||||
failedCount: row.error_count ?? null,
|
||||
successRate: row.success_rate ?? null,
|
||||
firstByteMs: row.avg_first_byte_time_ms ?? null,
|
||||
outputTps: row.avg_output_tps ?? null,
|
||||
})) ?? null)
|
||||
const historicalModels = computed<AnalysisRow[] | null>(() => data.value?.data.models?.map(row => ({
|
||||
id: row.model || null,
|
||||
label: row.model || t('未记录模型', 'Unrecorded model'),
|
||||
requestCount: row.request_count,
|
||||
failedCount: row.error_count ?? null,
|
||||
successRate: row.success_rate ?? null,
|
||||
firstByteMs: row.avg_first_byte_time_ms ?? null,
|
||||
outputTps: row.avg_output_tps ?? null,
|
||||
})) ?? null)
|
||||
const completeActivity = computed(() => props.activity?.coverage === 'complete' && props.activity.scope.kind === 'node' ? props.activity : null)
|
||||
const providerRows = computed(() => mergeLiveAnalysis(historicalProviders.value,
|
||||
completeActivity.value?.providers.map(row => ({ id: row.provider_id || null, label: row.provider || row.provider_id || t('未归属提供商', 'Unattributed provider'), concurrency: row.current_concurrency, rpm: row.requests_per_minute })) ?? null))
|
||||
const modelRows = computed(() => mergeLiveAnalysis(historicalModels.value,
|
||||
completeActivity.value?.models.map(row => ({ id: row.model || null, label: row.model || t('未记录模型', 'Unrecorded model'), concurrency: row.current_concurrency, rpm: row.requests_per_minute })) ?? null))
|
||||
const chartLabels = computed(() => data.value?.data.timeseries.map(point => timestamp(point.bucket_start, range.value.timezone)) || [])
|
||||
const requestChart = computed(() => ({
|
||||
labels: chartLabels.value,
|
||||
datasets: [
|
||||
{ label: t('请求数', 'Requests'), data: data.value?.data.timeseries.map(point => point.request_count) || [], borderColor: '#0d9488', pointRadius: 1, borderWidth: 2, tension: 0.1 },
|
||||
{ label: t('失败请求', 'Failed requests'), data: data.value?.data.timeseries.map(point => point.failed_request_count) || [], borderColor: '#db7d60', pointRadius: 1, borderWidth: 2, tension: 0.1 },
|
||||
],
|
||||
}))
|
||||
const latencyChart = computed(() => ({
|
||||
labels: chartLabels.value,
|
||||
datasets: (['p50', 'p95', 'p99'] as const).map((key, index) => ({ label: `${key.toUpperCase()} (ms)`, data: data.value?.data.timeseries.map(point => point.latency_ms?.[key] ?? null) || [], borderColor: ['#0d9488', '#d97706', '#e11d48'][index], pointRadius: 1, borderWidth: 2, tension: 0.1 })),
|
||||
}))
|
||||
const failureChart = computed(() => {
|
||||
const failures = [...(data.value?.data.errors || [])].sort((a, b) => b.count - a.count)
|
||||
return {
|
||||
labels: failures.map(row => row.reason),
|
||||
datasets: [{ label: t('失败请求', 'Failed requests'), data: failures.map(row => row.count), backgroundColor: '#db7d60', borderRadius: 4, maxBarThickness: 24 }],
|
||||
}
|
||||
})
|
||||
const failureChartOptions: ChartOptions<'bar'> = {
|
||||
indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { beginAtZero: true, ticks: { precision: 0 }, grid: { color: 'rgba(156, 163, 175, 0.1)' } },
|
||||
y: { grid: { display: false } },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<section
|
||||
data-runtime-focus
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('处理链路', 'Processing pipeline') }}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px]"
|
||||
>
|
||||
{{ t('实时', 'Live') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-3">
|
||||
<Card
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
:data-runtime-focus-group="group.key"
|
||||
class="min-w-0 p-4"
|
||||
>
|
||||
<div class="mb-3 flex min-h-5 flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="flex items-center gap-2 text-sm font-semibold">
|
||||
<component
|
||||
:is="group.icon"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ group.title }}
|
||||
</h3>
|
||||
<RouterLink
|
||||
v-if="group.key === 'upstream'"
|
||||
:to="link('/admin/health-monitor')"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ t('健康监控', 'Health monitor') }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="group.key === 'usage' && queueStatus"
|
||||
data-runtime-focus-queue-state
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ queueStatus }}
|
||||
</span>
|
||||
</div>
|
||||
<dl class="divide-y divide-border/50">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
:data-runtime-focus-item="item.key"
|
||||
class="flex items-center justify-between gap-3 py-2"
|
||||
>
|
||||
<dt
|
||||
class="text-xs text-muted-foreground"
|
||||
:title="item.description"
|
||||
>
|
||||
{{ item.label }}
|
||||
</dt>
|
||||
<dd class="shrink-0 text-sm font-semibold tabular-nums">
|
||||
{{ count(item.value) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { GitBranch, ListTodo, ShieldCheck } from 'lucide-vue-next'
|
||||
import { Badge, Card } from '@/components/ui'
|
||||
import { buildGatewayMetricsSummary } from '@/api/monitoring'
|
||||
import type { OverviewLive } from '@/api/overview'
|
||||
import { count } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { useOverviewQuery } from '../query'
|
||||
|
||||
const props = defineProps<{ snapshot: OverviewLive }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { link } = useOverviewQuery()
|
||||
const metrics = computed(() => props.snapshot.metrics || (props.snapshot.metrics_text ? buildGatewayMetricsSummary(props.snapshot.metrics_text) : null))
|
||||
const queueStatus = computed(() => {
|
||||
const queue = metrics.value?.usageQueue
|
||||
if (queue?.enabled === false) return t('未启用', 'Disabled')
|
||||
if (queue?.configured === false) return t('未配置', 'Not configured')
|
||||
if (queue?.unavailable === true || !queue || [queue.groupLag, queue.groupPending, queue.dlqLength].every(value => value == null)) return t('未采集', 'Unavailable')
|
||||
return ''
|
||||
})
|
||||
const groups = computed(() => {
|
||||
const m = metrics.value
|
||||
const upstream = props.snapshot.resilience?.error_statistics
|
||||
const postgres = m?.postgres?.unavailable === true || m?.postgres?.available === false ? undefined : m?.postgres
|
||||
const queue = queueStatus.value ? undefined : m?.usageQueue
|
||||
return [
|
||||
{
|
||||
key: 'upstream', title: t('上游可用性', 'Upstream availability'), icon: ShieldCheck,
|
||||
items: [
|
||||
{ key: 'open-circuits', label: t('打开熔断器', 'Open circuits'), value: upstream?.open_circuit_breakers },
|
||||
{ key: 'degraded-credentials', label: t('降级凭据', 'Degraded credentials'), value: upstream?.degraded_keys },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'requests', title: t('请求处理', 'Request processing'), icon: GitBranch,
|
||||
items: [
|
||||
{ key: 'candidate-queue', label: t('候选队列', 'Candidate queue'), value: m?.requestCandidateQueue?.depth },
|
||||
{ key: 'database-waiting', label: t('数据库等待', 'Database waits'), value: postgres?.waitingConnections, description: t('存在等待事件的活跃数据库会话', 'Active database sessions with a wait event') },
|
||||
{ key: 'lock-waiting', label: t('锁等待', 'Lock waits'), value: postgres?.lockWaitingConnections, description: t('数据库等待中正在等待锁的会话', 'Database sessions waiting for a lock') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'usage', title: t('用量落盘', 'Usage persistence'), icon: ListTodo,
|
||||
items: [
|
||||
{ key: 'usage-lag', label: t('待消费', 'Awaiting delivery'), value: queue?.groupLag, description: t('尚未投递给消费组的条目', 'Entries not yet delivered to the consumer group') },
|
||||
{ key: 'usage-pending', label: t('待确认', 'Awaiting acknowledgement'), value: queue?.groupPending, description: t('已投递但尚未确认完成的条目', 'Delivered entries not yet acknowledged') },
|
||||
{ key: 'usage-dead-letters', label: t('死信', 'Dead letters'), value: queue?.dlqLength, description: t('当前死信队列中的条目', 'Entries currently retained in the dead-letter queue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<section
|
||||
data-operations-core
|
||||
class="space-y-3"
|
||||
>
|
||||
<OverviewStatus
|
||||
:loading="loading && !data"
|
||||
:error="error"
|
||||
@retry="refresh"
|
||||
/>
|
||||
<div
|
||||
v-if="data"
|
||||
class="grid grid-cols-1 items-stretch gap-6 lg:grid-cols-2"
|
||||
>
|
||||
<section
|
||||
data-operations-hardware
|
||||
class="flex min-w-0 flex-col gap-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('硬件性能', 'Hardware performance') }}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px]"
|
||||
>
|
||||
{{ t('当前节点', 'Current node') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="grid flex-1 grid-cols-2 gap-3 sm:gap-4">
|
||||
<OperationsMetricCard
|
||||
v-for="item in hardwareMetrics"
|
||||
:key="item.label"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
data-operations-gateway
|
||||
class="flex min-w-0 flex-col gap-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('网关指标', 'Gateway metrics') }}
|
||||
</h2>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px]"
|
||||
>
|
||||
{{ t('实时', 'Live') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4">
|
||||
<OperationsMetricCard
|
||||
v-for="item in coreMetrics"
|
||||
:key="item.label"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<slot :snapshot="snapshot" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Activity, CheckCircle2, Cpu, Gauge, Hash, MemoryStick, Radio, TriangleAlert } from 'lucide-vue-next'
|
||||
import { overviewApi } from '@/api/overview'
|
||||
import { buildGatewayMetricsSummary } from '@/api/monitoring'
|
||||
import { Badge } from '@/components/ui'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import OperationsMetricCard from './OperationsMetricCard.vue'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count, percent } from '../format'
|
||||
|
||||
const props = defineProps<{ revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { data, loading, error, refresh } = useOverviewRequest(() => props.revision, signal => overviewApi.live(signal), { scopeKey: () => 'live' })
|
||||
const runtime = computed(() => {
|
||||
const snapshot = data.value?.data
|
||||
return snapshot?.metrics || (snapshot?.metrics_text ? buildGatewayMetricsSummary(snapshot.metrics_text) : null)
|
||||
})
|
||||
const snapshot = computed(() => data.value ? { ...data.value.data, metrics: runtime.value } : undefined)
|
||||
function memory(value: number | null | undefined) {
|
||||
if (value == null) return '-'
|
||||
return value >= 1024 ** 3 ? `${count(value / 1024 ** 3)} GiB` : `${count(value / 1024 ** 2)} MiB`
|
||||
}
|
||||
function basis(value: number | null | undefined) {
|
||||
return value == null ? '-' : `${count(value / 100)}%`
|
||||
}
|
||||
const hardwareMetrics = computed(() => {
|
||||
const process = runtime.value?.process
|
||||
const memoryNote = process?.systemMemoryUsedBytes != null && process.systemMemoryTotalBytes != null && process.systemMemoryTotalBytes > 0
|
||||
? `${memory(process.systemMemoryUsedBytes)} / ${memory(process.systemMemoryTotalBytes)}`
|
||||
: t('内存使用率', 'Memory usage')
|
||||
return [
|
||||
{ label: t('主机 CPU', 'Host CPU'), value: basis(process?.systemCpuUsageBasisPoints), icon: Cpu, note: t('CPU 使用率', 'CPU usage') },
|
||||
{ label: t('主机内存', 'Host memory'), value: process?.systemMemoryTotalBytes === 0 ? '-' : basis(process?.systemMemoryUsageBasisPoints), icon: MemoryStick, note: memoryNote },
|
||||
{ label: t('进程 CPU', 'Process CPU'), value: basis(process?.processCpuUsageBasisPoints), icon: Cpu, note: t('100% = 1 核', '100% = 1 core') },
|
||||
{ label: t('进程内存', 'Process memory'), value: memory(process?.processMemoryBytes), icon: MemoryStick, note: t('网关进程', 'Gateway process') },
|
||||
]
|
||||
})
|
||||
const coreMetrics = computed(() => {
|
||||
const metrics = data.value?.data.recent_activity?.data
|
||||
const recentNote = t('最近 60 秒', 'Last 60 seconds')
|
||||
const nodeNote = t('当前节点', 'Current node')
|
||||
return [
|
||||
{ label: 'RPM', value: count(metrics?.requests_per_minute), icon: Activity, note: recentNote },
|
||||
{ label: 'TPM', value: count(metrics?.tokens_per_minute), icon: Hash, note: recentNote },
|
||||
{ label: t('请求成功率', 'Success rate'), value: percent(metrics?.success_rate?.value), icon: CheckCircle2, note: recentNote },
|
||||
{ label: t('失败请求', 'Failed requests'), value: count(metrics?.failed_request_count), icon: TriangleAlert, note: recentNote },
|
||||
{ label: t('当前并发', 'Concurrent requests'), value: count(runtime.value?.local.inFlight), icon: Gauge, note: nodeNote },
|
||||
{ label: t('活跃流', 'Active streams'), value: count(runtime.value?.tunnel.activeStreams), icon: Radio, note: nodeNote },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
export interface AnalysisRow {
|
||||
id: string | null
|
||||
label: string
|
||||
requestCount: number
|
||||
failedCount: number | null
|
||||
/** Percentage points, from 0 to 100. */
|
||||
successRate: number | null
|
||||
firstByteMs: number | null
|
||||
outputTps: number | null
|
||||
concurrency?: number | null
|
||||
rpm?: number | null
|
||||
}
|
||||
|
||||
export interface LiveAnalysisRow {
|
||||
id: string | null
|
||||
label: string
|
||||
concurrency: number
|
||||
rpm: number
|
||||
}
|
||||
|
||||
/** A complete node snapshot makes absent groups a measured zero. An unavailable
|
||||
* snapshot must never turn those same groups into zero load. Include active
|
||||
* groups before their usage rows have reached the historical report. */
|
||||
export function mergeLiveAnalysis(
|
||||
historical: readonly AnalysisRow[] | null,
|
||||
live: readonly LiveAnalysisRow[] | null,
|
||||
): AnalysisRow[] | null {
|
||||
if (historical === null) return null
|
||||
const current = new Map(live?.map(row => [row.id, row]))
|
||||
const result = historical.map(row => {
|
||||
const activity = current.get(row.id)
|
||||
current.delete(row.id)
|
||||
return { ...row, concurrency: activity?.concurrency ?? (live === null ? null : 0), rpm: activity?.rpm ?? (live === null ? null : 0) }
|
||||
})
|
||||
for (const row of current.values()) {
|
||||
result.push({
|
||||
id: row.id, label: row.label, requestCount: 0, failedCount: 0,
|
||||
successRate: null, firstByteMs: null, outputTps: null,
|
||||
concurrency: row.concurrency, rpm: row.rpm,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function measuredCount(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
/** An unavailable or inconsistent denominator must not turn into a 0% share. */
|
||||
export function analysisShare(value: number | null, total: number | null): number | null {
|
||||
const part = measuredCount(value)
|
||||
const denominator = measuredCount(total)
|
||||
if (part === null || denominator === null || denominator === 0 || part > denominator) return null
|
||||
return part / denominator * 100
|
||||
}
|
||||
|
||||
function stableOrder(left: AnalysisRow, right: AnalysisRow): number {
|
||||
const leftKey = `${left.id ?? ''}\u0000${left.label}`
|
||||
const rightKey = `${right.id ?? ''}\u0000${right.label}`
|
||||
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0
|
||||
}
|
||||
|
||||
/** Known failures first, then traffic; unavailable failures follow measured zero. */
|
||||
export function rankAnalysisRows(rows: readonly AnalysisRow[]): AnalysisRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const failures = (measuredCount(right.failedCount) ?? -1) - (measuredCount(left.failedCount) ?? -1)
|
||||
const traffic = (measuredCount(right.requestCount) ?? -1) - (measuredCount(left.requestCount) ?? -1)
|
||||
return failures || traffic || stableOrder(left, right)
|
||||
})
|
||||
}
|
||||
|
||||
export interface AnalysisInsight {
|
||||
state: 'ready' | 'unavailable' | 'empty' | 'none'
|
||||
row: AnalysisRow | null
|
||||
share: number | null
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export function analyzeRows(rows: readonly AnalysisRow[] | null, totalRequests: number | null, totalFailures: number | null): {
|
||||
traffic: AnalysisInsight
|
||||
failures: AnalysisInsight
|
||||
} {
|
||||
const unavailable: AnalysisInsight = { state: 'unavailable', row: null, share: null, partial: false }
|
||||
if (rows === null) return { traffic: { ...unavailable }, failures: { ...unavailable } }
|
||||
const empty: AnalysisInsight = { state: 'empty', row: null, share: null, partial: false }
|
||||
if (!rows.length && totalRequests === 0) return { traffic: { ...empty }, failures: { ...empty } }
|
||||
|
||||
const trafficRow = [...rows].sort((left, right) =>
|
||||
(measuredCount(right.requestCount) ?? -1) - (measuredCount(left.requestCount) ?? -1) || stableOrder(left, right),
|
||||
).find(row => (measuredCount(row.requestCount) ?? 0) > 0)
|
||||
const failedRow = rankAnalysisRows(rows).find(row => (measuredCount(row.failedCount) ?? 0) > 0)
|
||||
const traffic: AnalysisInsight = trafficRow
|
||||
? { state: 'ready', row: trafficRow, share: analysisShare(trafficRow.requestCount, totalRequests), partial: rows.some(row => measuredCount(row.requestCount) === null) }
|
||||
: totalRequests === 0 ? { ...empty } : { ...unavailable }
|
||||
const failures: AnalysisInsight = failedRow
|
||||
? { state: 'ready', row: failedRow, share: analysisShare(failedRow.failedCount, totalFailures), partial: rows.some(row => measuredCount(row.failedCount) === null) }
|
||||
: totalFailures === 0
|
||||
? { state: 'none', row: null, share: null, partial: false }
|
||||
: { ...unavailable }
|
||||
return { traffic, failures }
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { computed, inject, provide, ref, type ComputedRef, type InjectionKey } from 'vue'
|
||||
import { useRoute, useRouter, type LocationQuery, type LocationQueryRaw } from 'vue-router'
|
||||
import type { OverviewQuery, OverviewRange } from '@/api/overview'
|
||||
|
||||
interface OverviewRangeContext {
|
||||
rolling: boolean
|
||||
range: ComputedRef<OverviewRange>
|
||||
relativePreset: ComputedRef<string | null>
|
||||
refreshRange: () => void
|
||||
}
|
||||
const overviewRangeKey: InjectionKey<OverviewRangeContext> = Symbol('overview-range')
|
||||
const relativePresets = ['today', 'last1hour', 'last24hours', 'last7days', 'last30days', 'last90days']
|
||||
|
||||
export function queryString(query: LocationQuery, key: string): string {
|
||||
const value = query[key]
|
||||
return (Array.isArray(value) ? value[0] : value) ?? ''
|
||||
}
|
||||
|
||||
export function browserTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
}
|
||||
|
||||
export function isTimezone(value: string): boolean {
|
||||
try { new Intl.DateTimeFormat('en-US', { timeZone: value }).format(); return true } catch { return false }
|
||||
}
|
||||
|
||||
export function zonedInput(instant: string | Date, timezone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(new Date(instant))
|
||||
const part = (key: string) => parts.find(p => p.type === key)?.value ?? ''
|
||||
return `${part('year')}-${part('month')}-${part('day')}T${part('hour')}:${part('minute')}`
|
||||
}
|
||||
|
||||
export function zonedInstant(value: string, timezone: string): string | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value) || !isTimezone(timezone)) return null
|
||||
const wall = Date.parse(`${value}:00Z`)
|
||||
if (!Number.isFinite(wall)) return null
|
||||
// Probe both sides of a DST transition. Gaps are invalid; repeated times choose the earlier instant.
|
||||
const offsets = new Set<number>()
|
||||
for (const hours of [-36, -12, 0, 12, 36]) {
|
||||
const probe = wall + hours * 3_600_000
|
||||
const formatted = Date.parse(`${zonedInput(new Date(probe), timezone)}:00Z`)
|
||||
offsets.add(formatted - probe)
|
||||
}
|
||||
const candidates = [...offsets].map(offset => wall - offset)
|
||||
.filter(candidate => zonedInput(new Date(candidate), timezone) === value).sort((a, b) => a - b)
|
||||
const candidate = candidates[0]
|
||||
return candidate === undefined ? null : new Date(candidate).toISOString()
|
||||
}
|
||||
|
||||
export function presetRange(preset: string, timezone = browserTimezone(), now = new Date()): OverviewRange {
|
||||
now = new Date(Math.floor(now.getTime() / 1000) * 1000)
|
||||
let start = now.getTime() - 7 * 86_400_000
|
||||
if (preset === 'last1hour') start = now.getTime() - 3_600_000
|
||||
if (preset === 'last24hours') start = now.getTime() - 86_400_000
|
||||
if (preset === 'last30days') start = now.getTime() - 30 * 86_400_000
|
||||
if (preset === 'last90days') start = now.getTime() - 90 * 86_400_000
|
||||
if (preset === 'today') {
|
||||
const midnight = zonedInstant(`${zonedInput(now, timezone).slice(0, 10)}T00:00`, timezone)
|
||||
if (midnight) start = Date.parse(midnight)
|
||||
}
|
||||
return { from: new Date(start).toISOString(), to: now.toISOString(), timezone }
|
||||
}
|
||||
|
||||
export function rangeFromQuery(query: LocationQuery, fallback: OverviewRange): OverviewRange {
|
||||
const from = queryString(query, 'from')
|
||||
const to = queryString(query, 'to')
|
||||
const timezone = queryString(query, 'timezone') || fallback.timezone
|
||||
if (from && to && Number.isFinite(Date.parse(from)) && Date.parse(from) < Date.parse(to) && isTimezone(timezone)) {
|
||||
return { from: new Date(from).toISOString(), to: new Date(to).toISOString(), timezone }
|
||||
}
|
||||
if (!from && !to && isTimezone(timezone)) {
|
||||
const startDate = queryString(query, 'start_date')
|
||||
const endDate = queryString(query, 'end_date')
|
||||
const shiftDate = (date: string, days: number) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10)
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(startDate) && /^\d{4}-\d{2}-\d{2}$/.test(endDate) && Number.isFinite(Date.parse(`${endDate}T00:00:00Z`))) {
|
||||
const start = zonedInstant(`${startDate}T00:00`, timezone)
|
||||
const end = zonedInstant(`${shiftDate(endDate, 1)}T00:00`, timezone)
|
||||
if (start && end && start < end) return { from: start, to: end, timezone }
|
||||
}
|
||||
const preset = queryString(query, 'preset')
|
||||
const legacyDays: Record<string, number> = { today: 1, yesterday: 1, last7days: 7, last30days: 30, last90days: 90 }
|
||||
if (legacyDays[preset]) {
|
||||
const today = zonedInput(new Date(fallback.to), timezone).slice(0, 10)
|
||||
const endDate = preset === 'yesterday' ? today : shiftDate(today, 1)
|
||||
const start = zonedInstant(`${shiftDate(endDate, -legacyDays[preset])}T00:00`, timezone)
|
||||
const end = zonedInstant(`${endDate}T00:00`, timezone)
|
||||
if (start && end) return { from: start, to: end, timezone }
|
||||
}
|
||||
if (['last1hour', 'last24hours'].includes(preset)) return presetRange(preset, timezone, new Date(fallback.to))
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function rangeQuery(range: OverviewRange): LocationQueryRaw {
|
||||
return { from: range.from, to: range.to, timezone: range.timezone }
|
||||
}
|
||||
|
||||
export function usageQuery(query: OverviewQuery): OverviewQuery {
|
||||
const { search: _search, account_status: _account, usage_status: _usage, sort: _sort, order: _order, offset: _offset, limit: _limit, group_by: _group, ...filters } = query
|
||||
return filters
|
||||
}
|
||||
|
||||
export function useOverviewQuery(defaultPreset = 'last7days', options: { rolling?: boolean } = {}) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const inheritedRange = inject(overviewRangeKey, null)
|
||||
const rolling = inheritedRange?.rolling ?? options.rolling ?? false
|
||||
const initialRange = presetRange(defaultPreset)
|
||||
const refreshedAt = ref(Date.parse(initialRange.to))
|
||||
const relativePreset = inheritedRange?.relativePreset ?? computed(() => {
|
||||
if (!rolling) return null
|
||||
const selected = queryString(route.query, 'relative_preset')
|
||||
if (relativePresets.includes(selected)) return selected
|
||||
if (['from', 'to', 'start_date', 'end_date'].some(key => queryString(route.query, key))) return null
|
||||
const legacyPreset = queryString(route.query, 'preset')
|
||||
return legacyPreset ? relativePresets.includes(legacyPreset) ? legacyPreset : null : defaultPreset
|
||||
})
|
||||
const range = inheritedRange?.range ?? computed(() => {
|
||||
if (!relativePreset.value) return rangeFromQuery(route.query, initialRange)
|
||||
const timezone = queryString(route.query, 'timezone') || initialRange.timezone
|
||||
const selectedAt = Date.parse(queryString(route.query, 'to'))
|
||||
return presetRange(relativePreset.value, isTimezone(timezone) ? timezone : initialRange.timezone,
|
||||
new Date(Math.max(refreshedAt.value, Number.isFinite(selectedAt) ? selectedAt : 0)))
|
||||
})
|
||||
const refreshRange = inheritedRange?.refreshRange ?? (() => {
|
||||
if (relativePreset.value) refreshedAt.value = Date.now()
|
||||
})
|
||||
provide(overviewRangeKey, { rolling, range, relativePreset, refreshRange })
|
||||
const query = computed<OverviewQuery>(() => {
|
||||
const value: OverviewQuery = { ...range.value }
|
||||
const textKeys = ['user_id', 'api_key_id', 'request_type', 'credential_owner_id', 'attribution_kind', 'model', 'provider_id', 'api_format', 'endpoint_kind', 'status', 'search', 'account_status', 'usage_status', 'sort', 'group_by', 'amount_basis'] as const
|
||||
for (const key of textKeys) {
|
||||
const text = queryString(route.query, key)
|
||||
if (text) value[key] = text
|
||||
}
|
||||
for (const key of ['is_stream', 'has_format_conversion'] as const) {
|
||||
const text = queryString(route.query, key)
|
||||
if (text === 'true' || text === 'false') value[key] = text === 'true'
|
||||
}
|
||||
for (const key of ['limit', 'offset', 'slow_threshold_ms'] as const) {
|
||||
const text = queryString(route.query, key)
|
||||
if (text && Number.isFinite(Number(text))) value[key] = Math.max(0, Math.floor(Number(text)))
|
||||
}
|
||||
value.order = queryString(route.query, 'order') === 'asc' ? 'asc' : 'desc'
|
||||
const granularity = queryString(route.query, 'granularity')
|
||||
value.granularity = granularity === 'hour' ? 'hour' : granularity === 'day' ? 'day'
|
||||
: Date.parse(range.value.to) - Date.parse(range.value.from) <= 48 * 3_600_000 ? 'hour' : 'day'
|
||||
return value
|
||||
})
|
||||
// Advancing a relative window is a refresh of the same selection, not a new filter scope.
|
||||
const scopeKey = computed(() => JSON.stringify(relativePreset.value
|
||||
? { ...query.value, from: undefined, to: undefined, relative_preset: relativePreset.value }
|
||||
: query.value))
|
||||
function patch(values: LocationQueryRaw, push = false) {
|
||||
const next = { ...route.query, start_date: undefined, end_date: undefined, preset: undefined, tz_offset_minutes: undefined, ...rangeQuery(range.value), relative_preset: relativePreset.value || undefined, ...values }
|
||||
return router[push ? 'push' : 'replace']({ query: next })
|
||||
}
|
||||
function setRange(value: OverviewRange, preset?: string) {
|
||||
return patch({ ...rangeQuery(value), relative_preset: rolling && preset && relativePresets.includes(preset) ? preset : undefined, offset: undefined })
|
||||
}
|
||||
function link(path: string, filters: LocationQueryRaw = {}) {
|
||||
return { path, query: { ...rangeQuery(range.value), ...filters } }
|
||||
}
|
||||
return { route, router, range, query, relativePreset, scopeKey, refreshRange, patch, setRange, link }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
import { useOverviewI18n } from './i18n'
|
||||
|
||||
export function useOverviewAutoRefresh(refreshRange: () => void) {
|
||||
const { t } = useOverviewI18n()
|
||||
const revision = ref(0)
|
||||
const autoRefresh = ref(false)
|
||||
const refreshTitle = computed(() => autoRefresh.value
|
||||
? t('关闭自动刷新(每 10 秒)', 'Stop auto refresh (every 10 seconds)')
|
||||
: t('开启自动刷新(每 10 秒)', 'Start auto refresh (every 10 seconds)'))
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
function refreshAll() { refreshRange(); revision.value += 1 }
|
||||
function stop() {
|
||||
autoRefresh.value = false
|
||||
if (timer !== undefined) clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
function toggleAutoRefresh() {
|
||||
if (autoRefresh.value) { stop(); return }
|
||||
autoRefresh.value = true
|
||||
refreshAll()
|
||||
timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') refreshAll()
|
||||
}, 10_000)
|
||||
}
|
||||
onScopeDispose(stop)
|
||||
return { revision, autoRefresh, refreshTitle, refreshAll, toggleAutoRefresh }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
import { overviewApi, type OverviewQuery } from '@/api/overview'
|
||||
|
||||
export function useOverviewExport() {
|
||||
const exporting = ref(false)
|
||||
const exportError = ref<string | null>(null)
|
||||
let controller: AbortController | null = null
|
||||
async function exportCsv(path: 'users' | 'consumption' | 'breakdown', query: OverviewQuery) {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
exporting.value = true
|
||||
exportError.value = null
|
||||
try {
|
||||
const blob = await overviewApi.exportCsv(path, query, controller.signal)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `aether-${path}-${query.from.slice(0, 10)}.csv`
|
||||
anchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted) return
|
||||
const response = cause as { response?: { data?: Blob }; message?: string }
|
||||
const body = response.response?.data
|
||||
if (body instanceof Blob) {
|
||||
try {
|
||||
const detail = JSON.parse(await body.text()) as { detail?: string; message?: string }
|
||||
exportError.value = detail.detail || detail.message || response.message || 'Export failed'
|
||||
} catch { exportError.value = response.message || 'Export failed' }
|
||||
} else exportError.value = response.message || 'Export failed'
|
||||
} finally { exporting.value = false }
|
||||
}
|
||||
onScopeDispose(() => controller?.abort())
|
||||
return { exporting, exportError, exportCsv }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { onScopeDispose, ref, shallowRef, toValue, watch, type WatchSource } from 'vue'
|
||||
|
||||
export function useOverviewRequest<T>(source: WatchSource, fetcher: (signal: AbortSignal) => Promise<T>, options: { scopeKey?: WatchSource<string> } = {}) {
|
||||
const data = shallowRef<T | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let controller: AbortController | null = null
|
||||
let generation = 0
|
||||
let previousScope: string | undefined
|
||||
async function refresh() {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const current = ++generation
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await fetcher(controller.signal)
|
||||
if (current === generation) data.value = result
|
||||
} catch (cause) {
|
||||
if (current !== generation || controller.signal.aborted) return
|
||||
const response = cause as { response?: { data?: { detail?: string; message?: string } }; message?: string }
|
||||
error.value = response.response?.data?.detail || response.response?.data?.message || response.message || 'Request failed'
|
||||
} finally {
|
||||
if (current === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
watch(source, () => {
|
||||
const scope = options.scopeKey ? toValue(options.scopeKey) : undefined
|
||||
if (!options.scopeKey || scope !== previousScope) data.value = null
|
||||
previousScope = scope
|
||||
void refresh()
|
||||
}, { immediate: true })
|
||||
onScopeDispose(() => { generation += 1; controller?.abort() })
|
||||
return { data, loading, error, refresh }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<section
|
||||
id="usage-records"
|
||||
class="min-w-0 scroll-mt-6 space-y-4 rounded-2xl border bg-card p-4 shadow-sm sm:p-5"
|
||||
data-user-consumption
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('使用记录', 'Usage records') }}
|
||||
</h2><Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="exporting"
|
||||
:title="t('导出完整筛选结果', 'Export all matching results')"
|
||||
:aria-label="t('导出完整筛选结果', 'Export all matching results')"
|
||||
@click="exportCsv('consumption', requestQuery)"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<OverviewStatus
|
||||
:loading="loading && !data"
|
||||
:error="error || exportError"
|
||||
@retry="refresh"
|
||||
/>
|
||||
<template v-if="data">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[820px] text-sm">
|
||||
<thead class="border-y text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="whitespace-nowrap py-3 text-left font-medium">
|
||||
{{ t('请求 / 时间', 'Request / time') }}
|
||||
</th><th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('模型', 'Model') }}
|
||||
</th><th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('状态', 'Status') }}
|
||||
</th><th
|
||||
v-for="label in amountLabels"
|
||||
:key="label"
|
||||
class="whitespace-nowrap px-3 py-3 text-right font-medium"
|
||||
>
|
||||
{{ label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead><tbody>
|
||||
<tr
|
||||
v-for="item in data.data.items"
|
||||
:key="item.id"
|
||||
class="border-b last:border-0"
|
||||
>
|
||||
<td class="max-w-60 py-3">
|
||||
<RouterLink
|
||||
:to="link('/admin/usage', { user_id: userId, detail_id: item.id, request_id: item.request_id })"
|
||||
class="block truncate font-mono text-xs text-primary hover:underline"
|
||||
:title="item.request_id"
|
||||
>
|
||||
{{ item.request_id }}
|
||||
</RouterLink><p class="mt-1 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{{ timestamp(item.started_at, range.timezone) }}
|
||||
</p>
|
||||
</td><td class="max-w-52 break-words px-3 py-3">
|
||||
{{ item.model || '-' }}<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ item.provider || '-' }}
|
||||
</p>
|
||||
</td><td class="px-3 py-3">
|
||||
<span :class="item.status === 'failed' ? 'text-destructive' : ''">{{ statusLabel(item.status) }}</span><p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ statusLabel(item.settlement_status) }}
|
||||
</p>
|
||||
</td><td
|
||||
v-for="key in amounts"
|
||||
:key="key"
|
||||
class="whitespace-nowrap px-3 py-3 text-right tabular-nums"
|
||||
:title="item[key]?.value ?? t('未知', 'Unknown')"
|
||||
>
|
||||
{{ money(item[key]) }}<span
|
||||
v-if="item[key]?.status === 'known_subtotal'"
|
||||
class="block text-xs text-muted-foreground"
|
||||
>{{ t('已知小计', 'Known subtotal') }}</span>
|
||||
</td>
|
||||
</tr><tr v-if="!data.data.items.length">
|
||||
<td
|
||||
colspan="6"
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{{ t('此时间范围暂无消费记录', 'No consumption in this range') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OverviewPagination
|
||||
:total="data.data.total"
|
||||
:offset="requestQuery.offset"
|
||||
:limit="requestQuery.limit"
|
||||
:loading="loading"
|
||||
@page="offset => patch({ offset })"
|
||||
@size="limit => patch({ limit, offset: undefined })"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Download } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import { overviewApi } from '@/api/overview'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import OverviewPagination from '../components/OverviewPagination.vue'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewExport } from '../useOverviewExport'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { money, timestamp } from '../format'
|
||||
const props = defineProps<{ userId: string; revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { query, range, relativePreset, patch, link } = useOverviewQuery()
|
||||
const requestQuery = computed(() => ({ ...range.value, user_id: props.userId, limit: Math.max(1, Math.min(100, query.value.limit || 25)), offset: query.value.offset || 0, sort: 'started_at', order: 'desc' as const }))
|
||||
const scope = computed(() => JSON.stringify({ ...requestQuery.value, ...(relativePreset.value ? { from: undefined, to: undefined, preset: relativePreset.value } : {}) }))
|
||||
const { data, loading, error, refresh } = useOverviewRequest(() => JSON.stringify([requestQuery.value, props.revision]), signal => overviewApi.consumption(requestQuery.value, signal), { scopeKey: scope })
|
||||
const { exporting, exportError, exportCsv } = useOverviewExport()
|
||||
const amounts = ['billable_amount', 'quota_covered_amount', 'wallet_debit_amount'] as const
|
||||
const amountLabels = computed(() => [t('消费', 'Consumption'), t('额度抵扣', 'Quota covered'), t('余额扣款', 'Balance debited')])
|
||||
function statusLabel(status: string) {
|
||||
const labels: Record<string, string> = { success: t('成功', 'Successful'), completed: t('已完成', 'Completed'), failed: t('失败', 'Failed'), cancelled: t('已取消', 'Cancelled'), settled: t('已结算', 'Settled'), pending: t('待结算', 'Pending'), unknown: t('未知', 'Unknown') }
|
||||
return labels[status] || status || '-'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="true"
|
||||
:title="`${user.username} · ${t('账目', 'Account')}`"
|
||||
:description="t('金额与套餐历史 · 余额为当前值', 'Balance and plan history · Balances are current')"
|
||||
size="4xl"
|
||||
placement="right"
|
||||
@update:model-value="value => !value && $emit('close')"
|
||||
>
|
||||
<template #header-actions>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
:title="t('刷新', 'Refresh')"
|
||||
@click="refresh"
|
||||
/>
|
||||
<Button
|
||||
ref="closeButton"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="t('关闭账目', 'Close account')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div
|
||||
class="space-y-6"
|
||||
data-user-account-drawer
|
||||
>
|
||||
<section
|
||||
class="space-y-3"
|
||||
data-account-transactions
|
||||
>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('金额变动', 'Balance changes') }}
|
||||
</h2>
|
||||
<OverviewStatus
|
||||
:error="walletError || transactionsError"
|
||||
@retry="refreshWallet"
|
||||
/>
|
||||
<dl
|
||||
v-if="wallet"
|
||||
class="grid grid-cols-1 gap-3 sm:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="item in balances"
|
||||
:key="item.label"
|
||||
class="rounded-xl border bg-muted/30 p-3"
|
||||
>
|
||||
<dt class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</dt>
|
||||
<dd class="mt-1 break-all text-lg font-semibold tabular-nums">
|
||||
{{ item.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<template v-if="wallet || walletLoading || transactionsLoading">
|
||||
<WalletTransactionsTable
|
||||
v-if="!transactionsError || transactions"
|
||||
:items="transactions?.items || []"
|
||||
:loading="walletLoading || transactionsLoading"
|
||||
:timezone="timezone"
|
||||
/>
|
||||
<Pagination
|
||||
v-if="transactions"
|
||||
:total="transactions.total"
|
||||
:current="Math.floor(offset / limit) + 1"
|
||||
:page-size="limit"
|
||||
:page-size-options="[25, 50, 100]"
|
||||
:aria-busy="transactionsLoading"
|
||||
@update:current="changePage"
|
||||
@update:page-size="resize"
|
||||
/>
|
||||
</template>
|
||||
<p
|
||||
v-else-if="!walletError"
|
||||
class="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('该用户尚无钱包', 'This user has no wallet yet') }}
|
||||
</p>
|
||||
</section>
|
||||
<section
|
||||
class="space-y-3"
|
||||
data-account-plans
|
||||
>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('套餐变动', 'Plan history') }}
|
||||
</h2>
|
||||
<OverviewStatus
|
||||
:error="plansError"
|
||||
@retry="refreshPlans"
|
||||
/>
|
||||
<UserPlanList
|
||||
v-if="!plansError || plans"
|
||||
:entitlements="plans?.items || []"
|
||||
:loading="plansLoading"
|
||||
:timezone="timezone"
|
||||
show-updated-at
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ t('关闭', 'Close') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Button, Dialog, Pagination, RefreshButton } from '@/components/ui'
|
||||
import { adminWalletApi } from '@/api/admin-wallets'
|
||||
import { usersApi } from '@/api/users'
|
||||
import WalletTransactionsTable from '@/features/wallet/components/WalletTransactionsTable.vue'
|
||||
import UserPlanList from '@/features/users/components/UserPlanList.vue'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { money } from '../format'
|
||||
|
||||
const props = defineProps<{ user: { user_id: string; username: string }; timezone: string }>()
|
||||
defineEmits<{ close: [] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const closeButton = ref<{ $el: HTMLButtonElement } | null>(null)
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
onMounted(() => closeButton.value?.$el.focus({ preventScroll: true }))
|
||||
onUnmounted(() => {
|
||||
void nextTick(() => {
|
||||
if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true })
|
||||
})
|
||||
})
|
||||
const revision = ref(0)
|
||||
const limit = ref(25)
|
||||
const offset = ref(0)
|
||||
watch(() => props.user.user_id, () => { offset.value = 0 }, { flush: 'sync' })
|
||||
const {
|
||||
data: wallets, loading: walletLoading, error: walletError, refresh: reloadWallet,
|
||||
} = useOverviewRequest(
|
||||
() => JSON.stringify([props.user.user_id, revision.value]),
|
||||
signal => adminWalletApi.listWallets({ user_id: props.user.user_id, owner_type: 'user', limit: 1, offset: 0 }, signal),
|
||||
{ scopeKey: () => props.user.user_id },
|
||||
)
|
||||
const walletId = computed(() => wallets.value?.items.find(item => item.user_id === props.user.user_id)?.id)
|
||||
const {
|
||||
data: transactions, loading: transactionsLoading, error: transactionsError, refresh: reloadTransactions,
|
||||
} = useOverviewRequest(
|
||||
() => JSON.stringify([walletId.value, limit.value, offset.value, revision.value]),
|
||||
signal => walletId.value
|
||||
? adminWalletApi.getWalletTransactions(walletId.value, { limit: limit.value, offset: offset.value }, signal)
|
||||
: Promise.resolve(null),
|
||||
)
|
||||
const {
|
||||
data: plans, loading: plansLoading, error: plansError, refresh: refreshPlans,
|
||||
} = useOverviewRequest(
|
||||
() => JSON.stringify([props.user.user_id, revision.value]),
|
||||
signal => usersApi.listUserPlanEntitlements(props.user.user_id, { include_inactive: true }, signal),
|
||||
{ scopeKey: () => props.user.user_id },
|
||||
)
|
||||
const wallet = computed(() => transactions.value?.wallet || wallets.value?.items.find(item => item.user_id === props.user.user_id))
|
||||
const balances = computed(() => [
|
||||
{ label: t('余额', 'Balance'), value: walletMoney(wallet.value?.balance) },
|
||||
{ label: t('充值', 'Recharge balance'), value: walletMoney(wallet.value?.recharge_balance) },
|
||||
{ label: t('赠送', 'Gift balance'), value: walletMoney(wallet.value?.gift_balance) },
|
||||
])
|
||||
const loading = computed(() => walletLoading.value || transactionsLoading.value || plansLoading.value)
|
||||
function walletMoney(value?: number) {
|
||||
return money({ value: value == null ? null : String(value), currency: wallet.value?.currency || 'USD', basis: 'balance', status: value == null ? 'unknown' : 'known' })
|
||||
}
|
||||
function refresh() { revision.value += 1 }
|
||||
function refreshWallet() {
|
||||
if (walletId.value && !walletError.value) void reloadTransactions()
|
||||
else void reloadWallet()
|
||||
}
|
||||
function changePage(value: number) {
|
||||
if (!transactionsLoading.value) offset.value = (value - 1) * limit.value
|
||||
}
|
||||
function resize(value: number) {
|
||||
if (transactionsLoading.value) return
|
||||
limit.value = value
|
||||
offset.value = 0
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<section
|
||||
class="rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-summary="consumption"
|
||||
>
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
{{ t('消费', 'Consumption') }}
|
||||
</p>
|
||||
<p class="mt-3 break-all text-2xl font-semibold tabular-nums">
|
||||
{{ money(summary?.billable_amount) }}
|
||||
</p>
|
||||
<p class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
<span class="whitespace-nowrap">{{ t('额度', 'Quota') }} {{ money(summary?.quota_covered_amount) }}</span>
|
||||
<span class="whitespace-nowrap">{{ t('余额', 'Balance') }} {{ money(summary?.wallet_debit_amount) }}</span>
|
||||
</p>
|
||||
</section>
|
||||
<section
|
||||
class="rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-summary="recharge"
|
||||
>
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
{{ t('充值', 'Recharges') }}
|
||||
</p>
|
||||
<p class="mt-3 break-all text-2xl font-semibold tabular-nums">
|
||||
{{ money(finance?.recharge_amount) }}
|
||||
</p>
|
||||
<p class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
<span class="whitespace-nowrap">{{ t('套餐', 'Plans') }} {{ money(finance?.plan_purchase_amount) }}</span>
|
||||
<span class="whitespace-nowrap">{{ t('赠送', 'Gifts') }} {{ money(finance?.gift_credit_amount) }}</span>
|
||||
</p>
|
||||
</section>
|
||||
<section
|
||||
class="rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-summary="balance"
|
||||
>
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
{{ t('余额', 'Balance') }}
|
||||
</p>
|
||||
<p class="mt-3 break-all text-2xl font-semibold tabular-nums">
|
||||
{{ money(finance?.wallet_balance) }}
|
||||
</p>
|
||||
<p class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
<span class="whitespace-nowrap">{{ t('充值', 'Recharges') }} {{ money(finance?.recharge_balance) }}</span>
|
||||
<span class="whitespace-nowrap">{{ t('赠送', 'Gifts') }} {{ money(finance?.gift_balance) }}</span>
|
||||
</p>
|
||||
</section>
|
||||
<section
|
||||
class="rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-summary="activity"
|
||||
>
|
||||
<p class="text-xs font-medium text-muted-foreground">
|
||||
{{ detail ? t('请求', 'Requests') : t('用户', 'Users') }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-3 text-2xl font-semibold tabular-nums"
|
||||
:title="detail ? undefined : t('活跃用户 / 总用户', 'Active users / Total users')"
|
||||
>
|
||||
{{ count(detail ? summary?.request_count : activeUserCount) }}<span
|
||||
v-if="!detail"
|
||||
class="ml-2 text-sm font-normal text-muted-foreground"
|
||||
>/ {{ count(userCount) }}</span>
|
||||
</p>
|
||||
<p class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
<span class="whitespace-nowrap">{{ detail ? t('成功', 'Successful') : t('请求', 'Requests') }} {{ count(detail ? summary?.successful_request_count : summary?.request_count) }}</span>
|
||||
<span class="whitespace-nowrap">Tokens {{ count(summary?.total_tokens) }}</span>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OverviewMetrics, OverviewUserFinance } from '@/api/overview'
|
||||
import { count, money } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
defineProps<{ summary?: OverviewMetrics | null; finance?: OverviewUserFinance | null; userCount?: number; activeUserCount?: number; detail?: boolean }>()
|
||||
const { t } = useOverviewI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<section
|
||||
class="min-w-0 rounded-2xl border bg-card p-4 shadow-sm sm:p-5"
|
||||
data-user-payments
|
||||
>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('充值与套餐记录', 'Recharge and plan records') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('按到账时间统计,赠送到账单独列示', 'Reported by credit time; gift credits are listed separately') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-x-auto"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<table class="w-full min-w-[650px] text-sm">
|
||||
<thead class="border-y text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 text-left font-medium">
|
||||
{{ t('订单', 'Order') }}
|
||||
</th><th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('类型', 'Type') }}
|
||||
</th><th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('支付方式', 'Payment method') }}
|
||||
</th><th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('到账金额', 'Credited amount') }}
|
||||
</th><th class="py-3 text-right font-medium">
|
||||
{{ t('到账时间', 'Credited at') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="payment in payments?.items || []"
|
||||
:key="payment.id"
|
||||
class="border-b last:border-0"
|
||||
>
|
||||
<td class="max-w-56 break-all py-3 font-mono text-xs">
|
||||
{{ payment.order_no || payment.id }}
|
||||
</td><td class="whitespace-nowrap px-3 py-3">
|
||||
{{ kindLabel(payment.kind) }}
|
||||
</td><td class="px-3 py-3 text-muted-foreground">
|
||||
{{ methodLabel(payment.payment_method) }}
|
||||
</td><td class="whitespace-nowrap px-3 py-3 text-right font-medium tabular-nums">
|
||||
{{ money(payment.amount) }}
|
||||
</td><td class="whitespace-nowrap py-3 text-right text-xs text-muted-foreground">
|
||||
{{ timestamp(payment.credited_at, timezone) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!payments?.items.length">
|
||||
<td
|
||||
colspan="5"
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{{ loading ? t('加载中', 'Loading') : !payments ? t('到账记录暂不可用', 'Credit records unavailable') : t('此时间范围暂无到账记录', 'No credited payments in this period') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OverviewPagination
|
||||
v-if="payments"
|
||||
:total="payments.total"
|
||||
:offset="offset"
|
||||
:limit="limit"
|
||||
:loading="loading"
|
||||
@page="value => $emit('page', value)"
|
||||
@size="value => $emit('size', value)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OverviewPage, OverviewUserPayment } from '@/api/overview'
|
||||
import OverviewPagination from '../components/OverviewPagination.vue'
|
||||
import { money, timestamp } from '../format'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
defineProps<{ payments?: OverviewPage<OverviewUserPayment> | null; timezone: string; loading: boolean; limit: number; offset: number }>()
|
||||
defineEmits<{ page: [value: number]; size: [value: number] }>()
|
||||
const { t } = useOverviewI18n()
|
||||
function kindLabel(kind: string) {
|
||||
return ({ wallet_recharge: t('钱包充值', 'Wallet recharge'), plan_purchase: t('套餐购买', 'Plan purchase'), gift_credit: t('赠送到账', 'Gift credit') })[kind] || kind
|
||||
}
|
||||
function methodLabel(method: string) {
|
||||
return ({ alipay: t('支付宝', 'Alipay'), wechat: t('微信支付', 'WeChat Pay'), stripe: 'Stripe', manual: t('人工入账', 'Manual credit'), gift_code: t('兑换码', 'Gift code') })[method] || method || '-'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-user-reports
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('统计报表', 'Reports') }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="!userId"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('全站用量 · 包含独立余额 Key · 不受用户搜索影响', 'Installation usage · Includes standalone balance keys · Independent of user search') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="exporting"
|
||||
@click="exportCsv('breakdown', modelQuery)"
|
||||
>
|
||||
<Download class="mr-2 h-4 w-4" />{{ t('导出模型报表', 'Export model report') }}
|
||||
</Button>
|
||||
</div>
|
||||
<OverviewStatus :error="exportError" />
|
||||
<div class="grid min-w-0 gap-4 xl:grid-cols-2">
|
||||
<section
|
||||
class="min-w-0 rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-report="consumption"
|
||||
>
|
||||
<h3 class="text-sm font-medium">
|
||||
{{ t('消费趋势', 'Consumption trend') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('计费消费 · USD', 'Billable consumption · USD') }}
|
||||
</p>
|
||||
<OverviewStatus
|
||||
:error="seriesError"
|
||||
@retry="refreshSeries"
|
||||
/>
|
||||
<div
|
||||
v-if="series?.data.items.length"
|
||||
class="mt-5 h-64 min-w-0"
|
||||
>
|
||||
<LineChart :data="consumptionChart" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-72 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ seriesLoading ? t('加载中', 'Loading') : seriesError ? t('消费报表暂不可用', 'Consumption report unavailable') : t('此时间范围暂无消费', 'No consumption in this period') }}
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="min-w-0 rounded-2xl border bg-card p-5 shadow-sm"
|
||||
data-user-report="models"
|
||||
>
|
||||
<h3 class="text-sm font-medium">
|
||||
{{ t('模型用量', 'Model usage') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('按 Token 用量排列 · 前 10 个模型', 'By token usage · Top 10 models') }}
|
||||
</p>
|
||||
<OverviewStatus
|
||||
:error="modelsError"
|
||||
@retry="refreshModels"
|
||||
/>
|
||||
<div
|
||||
v-if="models?.data.items.length"
|
||||
class="mt-5 h-64 min-w-0"
|
||||
>
|
||||
<BarChart
|
||||
:data="modelChart"
|
||||
:stacked="false"
|
||||
:options="modelOptions"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-72 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ modelsLoading ? t('加载中', 'Loading') : modelsError ? t('模型报表暂不可用', 'Model report unavailable') : t('此时间范围暂无模型使用', 'No model usage in this period') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="models?.data.items.length"
|
||||
class="mt-4 max-h-64 overflow-auto"
|
||||
>
|
||||
<table class="w-full min-w-[420px] text-xs">
|
||||
<thead class="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-2 text-left font-normal">
|
||||
{{ t('模型', 'Model') }}
|
||||
</th><th class="px-2 py-2 text-right font-normal">
|
||||
{{ t('请求数', 'Requests') }}
|
||||
</th><th class="px-2 py-2 text-right font-normal">
|
||||
Tokens
|
||||
</th><th class="py-2 text-right font-normal">
|
||||
{{ t('消费', 'Consumption') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in models.data.items"
|
||||
:key="item.id ?? '__unknown__'"
|
||||
class="border-b last:border-0"
|
||||
>
|
||||
<td class="max-w-40 break-words py-2">
|
||||
{{ item.label || item.id || t('未知模型', 'Unknown model') }}
|
||||
</td><td class="px-2 py-2 text-right tabular-nums">
|
||||
{{ count(item.request_count) }}
|
||||
</td><td class="px-2 py-2 text-right tabular-nums">
|
||||
{{ count(item.total_tokens) }}
|
||||
</td><td class="whitespace-nowrap py-2 text-right tabular-nums">
|
||||
{{ money(item.billable_amount) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChartOptions } from 'chart.js'
|
||||
import { Download } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui'
|
||||
import { overviewApi } from '@/api/overview'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import BarChart from '@/components/charts/BarChart.vue'
|
||||
import OverviewStatus from '../components/OverviewStatus.vue'
|
||||
import { useOverviewQuery } from '../query'
|
||||
import { useOverviewRequest } from '../useOverviewRequest'
|
||||
import { useOverviewExport } from '../useOverviewExport'
|
||||
import { useOverviewI18n } from '../i18n'
|
||||
import { count, money, timestamp } from '../format'
|
||||
const props = defineProps<{ userId?: string; revision: number }>()
|
||||
const { t } = useOverviewI18n()
|
||||
const { range, relativePreset } = useOverviewQuery()
|
||||
const requestQuery = computed(() => ({ ...range.value, user_id: props.userId, granularity: Date.parse(range.value.to) - Date.parse(range.value.from) <= 48 * 3_600_000 ? 'hour' as const : 'day' as const }))
|
||||
const modelQuery = computed(() => ({ ...requestQuery.value, group_by: 'model', sort: 'total_tokens', order: 'desc' as const, limit: 10, offset: 0 }))
|
||||
const scope = computed(() => JSON.stringify({ user: props.userId, ...(relativePreset.value ? { preset: relativePreset.value, timezone: range.value.timezone } : range.value) }))
|
||||
const { data: series, loading: seriesLoading, error: seriesError, refresh: refreshSeries } = useOverviewRequest(() => JSON.stringify([requestQuery.value, props.revision]), signal => overviewApi.timeseries(requestQuery.value, signal), { scopeKey: scope })
|
||||
const { data: models, loading: modelsLoading, error: modelsError, refresh: refreshModels } = useOverviewRequest(() => JSON.stringify([modelQuery.value, props.revision]), signal => overviewApi.breakdown(modelQuery.value, signal), { scopeKey: scope })
|
||||
const { exporting, exportError, exportCsv } = useOverviewExport()
|
||||
const consumptionChart = computed(() => ({
|
||||
labels: (series.value?.data.items || []).map(item => timestamp(item.bucket_start, range.value.timezone)),
|
||||
datasets: [{ label: t('消费 (USD)', 'Consumption (USD)'), data: (series.value?.data.items || []).map(item => {
|
||||
const value = item.billable_amount?.value
|
||||
return value == null || !Number.isFinite(Number(value)) ? null : Number(value)
|
||||
}), borderColor: '#cb694b', backgroundColor: '#cb694b', pointRadius: 2, borderWidth: 2, tension: 0.15 }],
|
||||
}))
|
||||
const modelChart = computed(() => ({ labels: (models.value?.data.items || []).map(item => item.label || item.id || t('未知模型', 'Unknown model')), datasets: [{ label: 'Tokens', data: (models.value?.data.items || []).map(item => item.total_tokens), backgroundColor: '#cb694b', borderRadius: 4, maxBarThickness: 24 }] }))
|
||||
const modelOptions: ChartOptions<'bar'> = { indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true }, y: { grid: { display: false } } } }
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { useOverviewAutoRefresh as useUserAnalysisRefresh } from '../useOverviewAutoRefresh'
|
||||
@@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<Card class="p-4 overflow-hidden">
|
||||
<Card
|
||||
class="p-4 overflow-hidden"
|
||||
:title="tooltip"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div
|
||||
v-if="hasData"
|
||||
class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0"
|
||||
class="items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0"
|
||||
:class="compact ? 'hidden sm:flex' : 'flex'"
|
||||
>
|
||||
<span class="flex-shrink-0">少</span>
|
||||
<div
|
||||
@@ -36,6 +40,7 @@
|
||||
v-else-if="hasData"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
:compact="compact"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
@@ -56,6 +61,8 @@ import type { ActivityHeatmap as ActivityHeatmapData } from '@/types/activity'
|
||||
const props = defineProps<{
|
||||
data: ActivityHeatmapData | null
|
||||
title: string
|
||||
tooltip?: string
|
||||
compact?: boolean
|
||||
isLoading?: boolean
|
||||
hasError?: boolean
|
||||
}>()
|
||||
|
||||
@@ -68,9 +68,11 @@ const props = withDefaults(defineProps<{
|
||||
isAdmin: boolean
|
||||
hours?: number
|
||||
refreshIntervalMs?: number
|
||||
data?: IntervalTimelineResponse | null
|
||||
}>(), {
|
||||
hours: 24, // 默认当天
|
||||
refreshIntervalMs: 30000
|
||||
refreshIntervalMs: 30000,
|
||||
data: undefined,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -292,6 +294,11 @@ const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
|
||||
|
||||
async function loadData() {
|
||||
const requestId = ++loadRequestId
|
||||
if (props.data !== undefined) {
|
||||
timelineData.value = props.data
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const limit = props.isAdmin ? ADMIN_TIMELINE_LIMIT : USER_TIMELINE_LIMIT
|
||||
@@ -332,6 +339,7 @@ function stopRefreshTimer() {
|
||||
}
|
||||
|
||||
function scheduleNextRefresh() {
|
||||
if (props.data !== undefined) return
|
||||
if (refreshTimer) return
|
||||
if (!isPageVisible.value) return
|
||||
if (!props.refreshIntervalMs || props.refreshIntervalMs <= 0) return
|
||||
@@ -355,7 +363,7 @@ function handleVisibilityChange() {
|
||||
scheduleNextRefresh()
|
||||
}
|
||||
|
||||
watch([() => props.hours, () => props.isAdmin], () => {
|
||||
watch([() => props.hours, () => props.isAdmin, () => props.data], () => {
|
||||
void loadData()
|
||||
stopRefreshTimer()
|
||||
scheduleNextRefresh()
|
||||
|
||||
@@ -137,6 +137,18 @@ describe('useUsageData', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('preserves precise overview scope and false performance filters in records and total requests', async () => {
|
||||
const { loadRecords } = useUsageData({ isAdminPage: ref(true) })
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({ records: [], total: 100, total_is_estimated: true })
|
||||
const range = { from: '2026-09-10T16:20:42.000Z', to: '2026-09-10T17:20:42.000Z', timezone: 'Asia/Shanghai' }
|
||||
const filters = { provider_id: 'provider-1', api_key_id: 'key-1', endpoint_kind: 'chat', request_type: 'standard', is_stream: false, has_format_conversion: false, slow_threshold_ms: 8000 }
|
||||
await loadRecords({ page: 2, pageSize: 20 }, filters, range)
|
||||
await flushMicrotasks()
|
||||
expect(getAllUsageRecordsMock).toHaveBeenCalledWith(expect.objectContaining({ ...range, ...filters, offset: 20 }))
|
||||
expect(getAllUsageRecordTotalMock).toHaveBeenCalledWith(expect.objectContaining({ ...range, ...filters }))
|
||||
expect(getAllUsageRecordsMock.mock.calls[0][0].preset).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sends the WebSocket type filter to the user records endpoint before pagination', async () => {
|
||||
const isAdminPage = ref(false)
|
||||
const { loadRecords } = useUsageData({ isAdminPage })
|
||||
|
||||
@@ -36,6 +36,15 @@ export interface PaginationParams {
|
||||
}
|
||||
|
||||
export interface FilterParams {
|
||||
provider_id?: string
|
||||
api_key_id?: string
|
||||
request_id?: string
|
||||
attribution_kind?: string
|
||||
endpoint_kind?: string
|
||||
request_type?: string
|
||||
is_stream?: boolean
|
||||
has_format_conversion?: boolean
|
||||
slow_threshold_ms?: number
|
||||
search?: string
|
||||
user_id?: string
|
||||
model?: string
|
||||
@@ -399,6 +408,12 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
|
||||
if (isAdminPage.value) {
|
||||
// 管理员页面:使用管理员 API
|
||||
for (const key of ['provider_id', 'api_key_id', 'request_id', 'attribution_kind', 'endpoint_kind', 'request_type'] as const) {
|
||||
if (filters?.[key]) params[key] = filters[key]
|
||||
}
|
||||
for (const key of ['is_stream', 'has_format_conversion', 'slow_threshold_ms'] as const) {
|
||||
if (filters?.[key] !== undefined) params[key] = filters[key]
|
||||
}
|
||||
if (filters?.user_id) {
|
||||
params.user_id = filters.user_id
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface ApiFormatStatsItem {
|
||||
// 请求状态类型
|
||||
// 日期范围参数
|
||||
export interface DateRangeParams {
|
||||
from?: string
|
||||
to?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
|
||||
@@ -43,70 +43,26 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingEntitlements"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
<UserPlanList
|
||||
:entitlements="entitlements"
|
||||
:loading="loadingEntitlements"
|
||||
:format-date-time="formatDateTime"
|
||||
:entitlement-labels="entitlementLabels"
|
||||
:empty-text="legacyT('当前没有有效套餐')"
|
||||
>
|
||||
{{ legacyT('正在加载用户套餐...') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="entitlements.length === 0"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('当前没有有效套餐') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="item in entitlements"
|
||||
:key="item.id"
|
||||
class="rounded-lg border border-border bg-card/80 p-3"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-foreground">
|
||||
{{ item.plan_title || item.plan?.title || item.plan_id }}
|
||||
</span>
|
||||
<Badge
|
||||
:variant="item.active ? 'success' : 'secondary'"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ item.active ? legacyT('生效中') : item.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="(label, index) in entitlementLabels(item.entitlements)"
|
||||
:key="`${label}-${index}`"
|
||||
variant="outline"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-start gap-2">
|
||||
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
|
||||
<div>{{ legacyT('开始:') }}{{ formatDateTime(item.starts_at) }}</div>
|
||||
<div>{{ legacyT('到期:') }}{{ formatDateTime(item.expires_at) }}</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="item.active"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="revokingEntitlementId === item.id"
|
||||
@click="$emit('revoke', item)"
|
||||
>
|
||||
{{ revokingEntitlementId === item.id ? legacyT('撤销中...') : legacyT('撤销套餐') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #actions="{ item }">
|
||||
<Button
|
||||
v-if="item.active"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="revokingEntitlementId === item.id"
|
||||
@click="$emit('revoke', item)"
|
||||
>
|
||||
{{ revokingEntitlementId === item.id ? legacyT('撤销中...') : legacyT('撤销套餐') }}
|
||||
</Button>
|
||||
</template>
|
||||
</UserPlanList>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-border bg-card/70 p-4">
|
||||
@@ -185,8 +141,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PackageCheck } from 'lucide-vue-next'
|
||||
import UserPlanList from './UserPlanList.vue'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
Select,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
{{ legacyT('正在加载用户套餐...') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!entitlements.length"
|
||||
class="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ emptyText || text('暂无套餐记录', 'No plan records') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="item in entitlements"
|
||||
:key="item.id"
|
||||
class="rounded-lg border border-border bg-card/80 p-3"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="break-words font-medium text-foreground">{{ item.plan_title || item.plan?.title || item.plan_id }}</span>
|
||||
<Badge
|
||||
:variant="item.active ? 'success' : 'secondary'"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ statusLabel(item) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="(label, index) in labels(item.entitlements)"
|
||||
:key="`${label}-${index}`"
|
||||
variant="outline"
|
||||
class="h-5 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-start gap-2">
|
||||
<div class="text-left text-[11px] text-muted-foreground sm:text-right">
|
||||
<div>{{ legacyT('开始:') }}{{ dateTime(item.starts_at) }}</div>
|
||||
<div>{{ legacyT('到期:') }}{{ dateTime(item.expires_at) }}</div>
|
||||
<div v-if="showUpdatedAt">
|
||||
{{ text('更新:', 'Updated: ') }}{{ dateTime(item.updated_at || item.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<slot
|
||||
name="actions"
|
||||
:item="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { AdminUserPlanEntitlement } from '@/api/users'
|
||||
import type { BillingEntitlement } from '@/api/billing'
|
||||
import { billingEntitlementLabels } from '@/utils/billingEntitlements'
|
||||
|
||||
const props = defineProps<{
|
||||
entitlements: AdminUserPlanEntitlement[]
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
showUpdatedAt?: boolean
|
||||
timezone?: string
|
||||
formatDateTime?: (value?: string | null) => string
|
||||
entitlementLabels?: (items: BillingEntitlement[] | undefined) => string[]
|
||||
}>()
|
||||
const { locale, legacyT } = useI18n()
|
||||
const text = (zh: string, en: string) => locale.value === 'zh-CN' ? zh : en
|
||||
const labels = (items: BillingEntitlement[]) => props.entitlementLabels?.(items) ?? billingEntitlementLabels(items, legacyT)
|
||||
function dateTime(value?: string | null) {
|
||||
if (props.formatDateTime) return props.formatDateTime(value)
|
||||
return value ? new Date(value).toLocaleString(locale.value, {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: props.timezone,
|
||||
}) : '-'
|
||||
}
|
||||
function statusLabel(item: AdminUserPlanEntitlement) {
|
||||
if (item.active) return text('生效中', 'Active')
|
||||
if (item.status === 'revoked') return text('已撤销', 'Revoked')
|
||||
if (item.status === 'replaced') return text('已替换', 'Replaced')
|
||||
if (item.status === 'expired' || (item.status === 'active' && item.expires_at && Date.parse(item.expires_at) <= Date.now())) return text('已到期', 'Expired')
|
||||
if (item.status === 'active' && item.starts_at && Date.parse(item.starts_at) > Date.now()) return text('待生效', 'Scheduled')
|
||||
return item.status
|
||||
}
|
||||
</script>
|
||||
@@ -264,74 +264,10 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
|
||||
<div class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>余额变化</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="tx in txItems"
|
||||
:key="tx.id"
|
||||
>
|
||||
<TableCell class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{{ formatDateTime(tx.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="font-mono"
|
||||
>
|
||||
{{ walletTransactionCategoryLabel(tx.category) }}
|
||||
</Badge>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ walletTransactionReasonLabel(tx.reason_code) }}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="tabular-nums"
|
||||
:class="toFiniteNumber(tx.amount) >= 0 ? 'text-emerald-600' : 'text-rose-600'"
|
||||
>
|
||||
{{ toFiniteNumber(tx.amount) >= 0 ? '+' : '' }}{{ formatFixed(tx.amount, 4) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs tabular-nums whitespace-nowrap">
|
||||
<div>{{ formatFixed(tx.balance_before, 4) }} → {{ formatFixed(tx.balance_after, 4) }}</div>
|
||||
<div
|
||||
v-if="tx.recharge_balance_before !== null && tx.recharge_balance_before !== undefined && tx.gift_balance_before !== null && tx.gift_balance_before !== undefined"
|
||||
class="text-[11px] text-muted-foreground mt-0.5"
|
||||
>
|
||||
充 {{ formatFixed(tx.recharge_balance_before, 4) }}→{{ formatFixed(tx.recharge_balance_after, 4) }}
|
||||
· 赠 {{ formatFixed(tx.gift_balance_before, 4) }}→{{ formatFixed(tx.gift_balance_after, 4) }}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground max-w-[260px] truncate">
|
||||
{{ tx.description || '-' }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loadingTx && txItems.length === 0">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
class="py-10"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无资金流水"
|
||||
description="当前钱包没有资金动作记录"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<WalletTransactionsTable
|
||||
:items="txItems"
|
||||
:loading="loadingTx"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
:current="txPage"
|
||||
@@ -509,7 +445,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
Badge,
|
||||
@@ -535,6 +470,7 @@ import {
|
||||
TabsTrigger,
|
||||
} from '@/components/ui'
|
||||
import { EmptyState } from '@/components/common'
|
||||
import WalletTransactionsTable from './WalletTransactionsTable.vue'
|
||||
import {
|
||||
adminWalletApi,
|
||||
type AdminWallet,
|
||||
@@ -548,8 +484,6 @@ import {
|
||||
refundStatusLabel,
|
||||
walletStatusBadge,
|
||||
walletStatusLabel,
|
||||
walletTransactionCategoryLabel,
|
||||
walletTransactionReasonLabel,
|
||||
} from '@/utils/walletDisplay'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -1026,17 +960,6 @@ async function submitCompleteRefund() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString(getI18nLocale(), {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown, fallback = 0): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
|
||||
<div
|
||||
class="overflow-x-auto"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>余额变化</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loading && !items.length">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
class="py-10 text-center text-muted-foreground"
|
||||
>
|
||||
{{ legacyT('加载中...') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-for="tx in items"
|
||||
:key="tx.id"
|
||||
>
|
||||
<TableCell class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{{ formatDateTime(tx.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="font-mono"
|
||||
>
|
||||
{{ walletTransactionCategoryLabel(tx.category) }}
|
||||
</Badge>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{{ walletTransactionReasonLabel(tx.reason_code) }}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="tabular-nums"
|
||||
:class="toFiniteNumber(tx.amount) >= 0 ? 'text-emerald-600' : 'text-rose-600'"
|
||||
>
|
||||
{{ toFiniteNumber(tx.amount) >= 0 ? '+' : '' }}{{ formatFixed(tx.amount, 4) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs tabular-nums whitespace-nowrap">
|
||||
<div>{{ formatFixed(tx.balance_before, 4) }} → {{ formatFixed(tx.balance_after, 4) }}</div>
|
||||
<div
|
||||
v-if="tx.recharge_balance_before !== null && tx.recharge_balance_before !== undefined && tx.gift_balance_before !== null && tx.gift_balance_before !== undefined"
|
||||
class="text-[11px] text-muted-foreground mt-0.5"
|
||||
>
|
||||
充 {{ formatFixed(tx.recharge_balance_before, 4) }}→{{ formatFixed(tx.recharge_balance_after, 4) }}
|
||||
· 赠 {{ formatFixed(tx.gift_balance_before, 4) }}→{{ formatFixed(tx.gift_balance_after, 4) }}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground max-w-[260px] truncate">
|
||||
{{ tx.description || '-' }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loading && items.length === 0">
|
||||
<TableCell
|
||||
colspan="5"
|
||||
class="py-10"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无资金流水"
|
||||
description="当前钱包没有资金动作记录"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n, getI18nLocale } from '@/i18n'
|
||||
import { Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
|
||||
import { EmptyState } from '@/components/common'
|
||||
import type { WalletTransaction } from '@/api/wallet'
|
||||
import { walletTransactionCategoryLabel, walletTransactionReasonLabel } from '@/utils/walletDisplay'
|
||||
|
||||
const props = defineProps<{ items: WalletTransaction[]; loading?: boolean; timezone?: string }>()
|
||||
const { legacyT } = useI18n()
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString(getI18nLocale(), {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
timeZone: props.timezone,
|
||||
})
|
||||
}
|
||||
function toFiniteNumber(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
function formatFixed(value: unknown, digits: number) {
|
||||
return toFiniteNumber(value).toFixed(digits)
|
||||
}
|
||||
</script>
|
||||
@@ -345,7 +345,7 @@ export const messages = {
|
||||
'nav.myReferral': '我的邀请',
|
||||
'nav.usageStats': '使用统计',
|
||||
'nav.operations': '运维总览',
|
||||
'nav.userStats': '用户统计',
|
||||
'nav.userStats': '用户分析',
|
||||
'nav.costAnalysis': '成本分析',
|
||||
'nav.performanceAnalysis': '性能分析',
|
||||
'nav.userManagement': '用户管理',
|
||||
@@ -791,7 +791,7 @@ export const messages = {
|
||||
'nav.myReferral': 'Referrals',
|
||||
'nav.usageStats': 'Usage stats',
|
||||
'nav.operations': 'Operations',
|
||||
'nav.userStats': 'User stats',
|
||||
'nav.userStats': 'User analysis',
|
||||
'nav.costAnalysis': 'Cost analysis',
|
||||
'nav.performanceAnalysis': 'Performance analysis',
|
||||
'nav.userManagement': 'Users',
|
||||
@@ -1121,6 +1121,9 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'暂无连接事件记录': 'No connection events',
|
||||
'节点数据': 'Node data',
|
||||
'最近 24 小时': 'Last 24 hours',
|
||||
'最近 1 小时': 'Last hour',
|
||||
'已选时段': 'Selected range',
|
||||
'时间范围': 'Time range',
|
||||
'加载节点数据...': 'Loading node data...',
|
||||
'加载节点数据失败': 'Failed to load node data',
|
||||
'加载事件失败': 'Failed to load events',
|
||||
|
||||
@@ -198,7 +198,23 @@
|
||||
</RouterLink>
|
||||
|
||||
<!-- Right Actions -->
|
||||
<div class="flex shrink-0 items-center gap-0.5 sm:gap-3">
|
||||
<div class="flex shrink-0 items-center gap-0 sm:gap-3 max-sm:[&_button]:h-8 max-sm:[&_button]:w-8">
|
||||
<AnnouncementBell
|
||||
:open="announcementPanel === 'mobile'"
|
||||
:items="announcementStore.items"
|
||||
:unread-count="announcementStore.unreadCount"
|
||||
:loading="announcementStore.loading"
|
||||
:error="announcementStore.error"
|
||||
:has-more="announcementStore.hasMore"
|
||||
:marking-all="announcementStore.markingAll"
|
||||
:can-manage="authStore.canOperateAdmin"
|
||||
@update:open="setAnnouncementPanel('mobile', $event)"
|
||||
@select="openAnnouncement"
|
||||
@refresh="refreshAnnouncements"
|
||||
@load-more="announcementStore.loadList(true)"
|
||||
@read-all="markAllAnnouncementsRead"
|
||||
@create="openAnnouncementComposer"
|
||||
/>
|
||||
<VersionButton
|
||||
v-if="isAdmin"
|
||||
:status="versionStatus"
|
||||
@@ -385,6 +401,22 @@
|
||||
id="header-actions-right"
|
||||
class="flex items-center"
|
||||
/>
|
||||
<AnnouncementBell
|
||||
:open="announcementPanel === 'desktop'"
|
||||
:items="announcementStore.items"
|
||||
:unread-count="announcementStore.unreadCount"
|
||||
:loading="announcementStore.loading"
|
||||
:error="announcementStore.error"
|
||||
:has-more="announcementStore.hasMore"
|
||||
:marking-all="announcementStore.markingAll"
|
||||
:can-manage="authStore.canOperateAdmin"
|
||||
@update:open="setAnnouncementPanel('desktop', $event)"
|
||||
@select="openAnnouncement"
|
||||
@refresh="refreshAnnouncements"
|
||||
@load-more="announcementStore.loadList(true)"
|
||||
@read-all="markAllAnnouncementsRead"
|
||||
@create="openAnnouncementComposer"
|
||||
/>
|
||||
<VersionButton
|
||||
v-if="isAdmin"
|
||||
:status="versionStatus"
|
||||
@@ -422,42 +454,15 @@
|
||||
|
||||
<RouterView />
|
||||
|
||||
<Dialog
|
||||
<AnnouncementDialog
|
||||
v-model="requiredAnnouncementOpen"
|
||||
persistent
|
||||
size="lg"
|
||||
:title="t('announcement.requiredTitle')"
|
||||
:description="t('announcement.requiredDescription')"
|
||||
>
|
||||
<div
|
||||
v-if="currentRequiredAnnouncement"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-foreground">
|
||||
{{ currentRequiredAnnouncement.title }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ formatRequiredAnnouncementDate(currentRequiredAnnouncement.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-h-[50vh] max-w-none overflow-y-auto"
|
||||
v-html="renderRequiredAnnouncement(currentRequiredAnnouncement.content)"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
type="button"
|
||||
:disabled="acknowledgingRequiredAnnouncement"
|
||||
@click="acknowledgeRequiredAnnouncement"
|
||||
>
|
||||
{{ acknowledgingRequiredAnnouncement ? t('common.confirming') : t('common.confirmRead') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
:announcement="currentRequiredAnnouncement"
|
||||
:persistent="announcementNeedsConfirmation"
|
||||
:confirm-read="!!currentRequiredAnnouncement && !currentRequiredAnnouncement.is_read"
|
||||
:confirming="acknowledgingRequiredAnnouncement"
|
||||
:error="announcementReadError"
|
||||
@confirm="acknowledgeRequiredAnnouncement"
|
||||
/>
|
||||
|
||||
<!-- 更新提示弹窗 -->
|
||||
<UpdateDialog
|
||||
@@ -490,20 +495,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, provide, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { marked } from 'marked'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import { useSiteInfo } from '@/composables/useSiteInfo'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { isDemoMode } from '@/config/demo'
|
||||
import { adminApi, type CheckUpdateResponse, type ReleaseEntry, type SystemUpdateCapabilityResponse, type UpdateTaskStatusResponse } from '@/api/admin'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import type { Announcement } from '@/api/announcements'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
import SidebarNav from '@/components/layout/SidebarNav.vue'
|
||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||
@@ -511,6 +515,9 @@ import LanguageSwitcher from '@/components/common/LanguageSwitcher.vue'
|
||||
import ThemeModeButton from '@/components/common/ThemeModeButton.vue'
|
||||
import UpdateDialog from '@/components/common/UpdateDialog.vue'
|
||||
import VersionButton from '@/components/common/VersionButton.vue'
|
||||
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
|
||||
import AnnouncementDialog from '@/components/common/AnnouncementDialog.vue'
|
||||
import { openAnnouncementKey } from '@/components/common/announcementContext'
|
||||
import { buildUpdateErrorStatus } from '@/utils/updateStatus'
|
||||
import { safeExternalHttpsUrl } from '@/utils/navigationSecurity'
|
||||
import {
|
||||
@@ -526,7 +533,6 @@ import {
|
||||
|
||||
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
||||
import { prefetchNavigationTarget } from '@/utils/adminNavigationPrefetch'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import { useI18n, type MessageKey } from '@/i18n'
|
||||
import { buildBreadcrumbs, buildNavigation } from './main-layout/navigation'
|
||||
|
||||
@@ -536,6 +542,7 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const moduleStore = useModuleStore()
|
||||
const announcementStore = useAnnouncementStore()
|
||||
const { siteName, siteSubtitle } = useSiteInfo()
|
||||
const { success, error: showError } = useToast()
|
||||
const { t, locale } = useI18n()
|
||||
@@ -545,15 +552,22 @@ const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
const showAuthError = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
const sidebarCollapsed = useLocalStorage('aether-sidebar-collapsed', false)
|
||||
const requiredAnnouncements = ref<Announcement[]>([])
|
||||
const announcementText = (zh: string, en: string) => locale.value === 'zh-CN' ? zh : en
|
||||
const announcementPanel = ref<'desktop' | 'mobile' | null>(null)
|
||||
const manualAnnouncement = ref<Announcement | null>(null)
|
||||
const announcementReadError = ref('')
|
||||
const acknowledgingRequiredAnnouncement = ref(false)
|
||||
let announcementReadRequestId = 0
|
||||
const currentRequiredAnnouncement = computed(() => manualAnnouncement.value ?? announcementStore.requiredItems[0] ?? null)
|
||||
const announcementNeedsConfirmation = computed(() => !!currentRequiredAnnouncement.value?.requires_ack && !currentRequiredAnnouncement.value.is_read)
|
||||
const requiredAnnouncementOpen = computed({
|
||||
get: () => requiredAnnouncements.value.length > 0,
|
||||
get: () => !!currentRequiredAnnouncement.value,
|
||||
set: (value) => {
|
||||
if (value) void loadRequiredAnnouncements()
|
||||
if (!value && !announcementNeedsConfirmation.value) manualAnnouncement.value = null
|
||||
}
|
||||
})
|
||||
const currentRequiredAnnouncement = computed(() => requiredAnnouncements.value[0] ?? null)
|
||||
let announcementPollTimer: number | null = null
|
||||
provide(openAnnouncementKey, openAnnouncement)
|
||||
|
||||
// 更新检查相关
|
||||
const showUpdateDialog = ref(false)
|
||||
@@ -583,7 +597,6 @@ const VERSION_STATUS_ERROR_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
|
||||
let updateStatusPollTimer: number | null = null
|
||||
let updateCheckTimer: number | null = null
|
||||
let requiredAnnouncementsPromise: Promise<void> | null = null
|
||||
const updateProgressPercent = computed(() => updateTaskStatus.value?.progress_percent ?? null)
|
||||
const updateProgressText = computed(() => formatUpdateProgressText(updateTaskStatus.value))
|
||||
const updateDialogTitle = computed(() => {
|
||||
@@ -1099,62 +1112,88 @@ function syncAuthNotice() {
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden) {
|
||||
syncAuthNotice()
|
||||
void announcementStore.refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [authStore.user, authStore.token] as const,
|
||||
() => {
|
||||
() => [authStore.user?.id, !!authStore.token] as const,
|
||||
([userId, authenticated]) => {
|
||||
showAuthError.value = !!authStore.user && !authStore.token
|
||||
if (authStore.user && authStore.token) {
|
||||
void loadRequiredAnnouncements()
|
||||
} else {
|
||||
requiredAnnouncements.value = []
|
||||
}
|
||||
announcementPanel.value = null
|
||||
manualAnnouncement.value = null
|
||||
announcementReadError.value = ''
|
||||
announcementReadRequestId += 1
|
||||
acknowledgingRequiredAnnouncement.value = false
|
||||
announcementStore.resetSession(userId && authenticated ? userId : null)
|
||||
if (userId && authenticated) void announcementStore.refreshStatus()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function loadRequiredAnnouncements() {
|
||||
if (!authStore.user || !authStore.token) return
|
||||
if (requiredAnnouncementsPromise) return requiredAnnouncementsPromise
|
||||
watch(() => currentRequiredAnnouncement.value?.id, () => {
|
||||
announcementReadError.value = ''
|
||||
if (currentRequiredAnnouncement.value) announcementPanel.value = null
|
||||
})
|
||||
|
||||
requiredAnnouncementsPromise = (async () => {
|
||||
try {
|
||||
const response = await announcementApi.getRequiredUnreadAnnouncements()
|
||||
requiredAnnouncements.value = response.items.filter(item => item.requires_ack && !item.is_read)
|
||||
} catch {
|
||||
requiredAnnouncements.value = []
|
||||
} finally {
|
||||
requiredAnnouncementsPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return requiredAnnouncementsPromise
|
||||
function setAnnouncementPanel(panel: 'desktop' | 'mobile', open: boolean) {
|
||||
announcementPanel.value = open ? panel : announcementPanel.value === panel ? null : announcementPanel.value
|
||||
if (open) void refreshAnnouncements()
|
||||
}
|
||||
|
||||
function renderRequiredAnnouncement(content: string): string {
|
||||
return sanitizeMarkdown(marked(content || '') as string)
|
||||
async function refreshAnnouncements() {
|
||||
await Promise.all([announcementStore.loadList(), announcementStore.refreshStatus()])
|
||||
}
|
||||
|
||||
function formatRequiredAnnouncementDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value)
|
||||
function closeAnnouncementPanel() {
|
||||
announcementPanel.value = null
|
||||
}
|
||||
|
||||
function openAnnouncement(announcement: Announcement) {
|
||||
closeAnnouncementPanel()
|
||||
manualAnnouncement.value = announcement
|
||||
announcementReadError.value = ''
|
||||
if (!announcement.is_read && !announcement.requires_ack) void acknowledgeRequiredAnnouncement()
|
||||
}
|
||||
|
||||
function openAnnouncementComposer() {
|
||||
if (!authStore.canOperateAdmin) return
|
||||
closeAnnouncementPanel()
|
||||
void router.push({ path: '/admin/announcements', query: { create: '1' } })
|
||||
}
|
||||
|
||||
async function markAllAnnouncementsRead() {
|
||||
try {
|
||||
await announcementStore.markAllRead()
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, announcementText('标记已读失败', 'Could not mark announcements as read')))
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledgeRequiredAnnouncement() {
|
||||
const announcement = currentRequiredAnnouncement.value
|
||||
if (!announcement) return
|
||||
const requestId = ++announcementReadRequestId
|
||||
acknowledgingRequiredAnnouncement.value = true
|
||||
try {
|
||||
await announcementApi.markAsRead(announcement.id)
|
||||
requiredAnnouncements.value = requiredAnnouncements.value.slice(1)
|
||||
await announcementStore.markRead(announcement.id)
|
||||
if (requestId !== announcementReadRequestId) return
|
||||
if (manualAnnouncement.value?.id === announcement.id) {
|
||||
if (announcement.requires_ack) manualAnnouncement.value = null
|
||||
else manualAnnouncement.value = { ...announcement, is_read: true }
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId === announcementReadRequestId) {
|
||||
announcementReadError.value = parseApiError(err, announcementText('标记已读失败,请重试', 'Could not mark as read. Please retry.'))
|
||||
}
|
||||
} finally {
|
||||
acknowledgingRequiredAnnouncement.value = false
|
||||
if (requestId === announcementReadRequestId) acknowledgingRequiredAnnouncement.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.addEventListener('resize', closeAnnouncementPanel)
|
||||
syncAuthNotice()
|
||||
applyCachedVersionStatus()
|
||||
|
||||
@@ -1164,7 +1203,9 @@ onMounted(() => {
|
||||
// 路由守卫会在需要模块状态时按需处理失败场景。
|
||||
})
|
||||
}
|
||||
void loadRequiredAnnouncements()
|
||||
announcementPollTimer = window.setInterval(() => {
|
||||
if (!document.hidden) void announcementStore.refreshStatus()
|
||||
}, 60_000)
|
||||
|
||||
// 延迟检查更新,避免 GitHub Releases 检查和首屏业务数据争抢资源。
|
||||
updateCheckTimer = window.setTimeout(() => {
|
||||
@@ -1180,6 +1221,10 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('resize', closeAnnouncementPanel)
|
||||
if (announcementPollTimer !== null) window.clearInterval(announcementPollTimer)
|
||||
announcementReadRequestId += 1
|
||||
announcementStore.resetSession(null)
|
||||
if (updateCheckTimer !== null) {
|
||||
window.clearTimeout(updateCheckTimer)
|
||||
updateCheckTimer = null
|
||||
|
||||
@@ -85,10 +85,17 @@ describe('main layout navigation builder', () => {
|
||||
|
||||
const overviewItems = adminNavigation.find(group => group.title === 'tx:nav.group.overview')?.items ?? []
|
||||
expect(overviewItems.findIndex(item => item.name === '远程控制')).toBe(
|
||||
overviewItems.findIndex(item => item.name === 'tx:nav.performanceAnalysis') + 1,
|
||||
overviewItems.findIndex(item => item.name === 'tx:nav.healthMonitor') + 1,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the five fixed overview destinations in product order', () => {
|
||||
const navigation = buildNavigation({ canAccessAdmin: true, modules: {}, isModuleActive: () => false })
|
||||
expect(navigation[0]?.items.map(item => item.href)).toEqual([
|
||||
'/admin/dashboard', '/admin/operations', '/admin/user-stats', '/admin/cost-analysis', '/admin/health-monitor',
|
||||
])
|
||||
})
|
||||
|
||||
it('builds admin navigation with dynamic module menu items sorted by menu order', () => {
|
||||
const navigation = buildNavigation({
|
||||
canAccessAdmin: true,
|
||||
|
||||
@@ -119,10 +119,9 @@ export function buildNavigation(options: {
|
||||
items: [
|
||||
{ name: t('nav.dashboard'), href: '/admin/dashboard', icon: Home },
|
||||
{ name: t('nav.operations'), href: '/admin/operations', icon: Activity },
|
||||
{ name: t('nav.healthMonitor'), href: '/admin/health-monitor', icon: Activity },
|
||||
{ name: t('nav.userStats'), href: '/admin/user-stats', icon: BarChart3 },
|
||||
{ name: t('nav.userStats'), href: '/admin/user-stats', icon: Users },
|
||||
{ name: t('nav.costAnalysis'), href: '/admin/cost-analysis', icon: Gauge },
|
||||
{ name: t('nav.performanceAnalysis'), href: '/admin/performance-analysis', icon: Activity },
|
||||
{ name: t('nav.healthMonitor'), href: '/admin/health-monitor', icon: Activity },
|
||||
...activeModuleItems(modules, 'overview'),
|
||||
]
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: view(() => import('@/views/shared/Dashboard.vue'))
|
||||
component: view(() => import('@/views/admin/AdminDashboard.vue'))
|
||||
},
|
||||
{
|
||||
path: 'operations',
|
||||
@@ -103,10 +103,15 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
name: 'CostAnalysis',
|
||||
component: view(() => import('@/views/admin/CostAnalysis.vue'))
|
||||
},
|
||||
{
|
||||
path: 'user-stats/:userId',
|
||||
name: 'UserAnalysisDetail',
|
||||
component: view(() => import('@/views/admin/UserAnalysisDetail.vue'))
|
||||
},
|
||||
{
|
||||
path: 'performance-analysis',
|
||||
name: 'PerformanceAnalysis',
|
||||
component: view(() => import('@/views/admin/PerformanceAnalysis.vue'))
|
||||
redirect: to => ({ path: '/admin/operations', query: { ...to.query, view: undefined } })
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
import { view } from './helpers'
|
||||
|
||||
export const publicRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/status',
|
||||
name: 'PublicStatus',
|
||||
component: view(() => import('@/views/public/Status.vue')),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { Announcement, AnnouncementListResponse } from '@/api/announcements'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUserAnnouncements: vi.fn(),
|
||||
getUnreadCount: vi.fn(),
|
||||
getRequiredUnreadAnnouncements: vi.fn(),
|
||||
markAsRead: vi.fn(),
|
||||
markAllAsRead: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/announcements', () => ({ announcementApi: api }))
|
||||
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
|
||||
function notice(id: string, overrides: Partial<Announcement> = {}): Announcement {
|
||||
return {
|
||||
id,
|
||||
title: `Notice ${id}`,
|
||||
content: `Content ${id}`,
|
||||
type: 'info',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_active: true,
|
||||
requires_ack: false,
|
||||
is_read: false,
|
||||
author: { id: 'author', username: 'Author' },
|
||||
created_at: '2026-09-15T00:00:00Z',
|
||||
updated_at: '2026-09-15T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function page(items: Announcement[], total: number, unread_count: number): AnnouncementListResponse {
|
||||
return { items, total, unread_count }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.resetAllMocks()
|
||||
api.getUserAnnouncements.mockResolvedValue(page([], 0, 0))
|
||||
api.getUnreadCount.mockResolvedValue({ unread_count: 0 })
|
||||
api.getRequiredUnreadAnnouncements.mockResolvedValue(page([], 0, 0))
|
||||
api.markAsRead.mockResolvedValue({ message: 'read' })
|
||||
api.markAllAsRead.mockResolvedValue({ message: 'read all' })
|
||||
})
|
||||
|
||||
describe('announcement store loading', () => {
|
||||
it('does not fetch while logged out and clears all personal state on logout', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
await Promise.all([store.refresh(), store.loadList(), store.markRead('notice'), store.markAllRead()])
|
||||
expect(api.getUserAnnouncements).not.toHaveBeenCalled()
|
||||
expect(api.getUnreadCount).not.toHaveBeenCalled()
|
||||
expect(api.markAsRead).not.toHaveBeenCalled()
|
||||
expect(api.markAllAsRead).not.toHaveBeenCalled()
|
||||
|
||||
store.resetSession('alice')
|
||||
api.getUserAnnouncements.mockResolvedValue(page([notice('alice-notice')], 50, 40))
|
||||
await store.loadList()
|
||||
store.resetSession(null)
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.requiredItems).toEqual([])
|
||||
expect(store.unreadCount).toBeNull()
|
||||
expect(store.total).toBe(0)
|
||||
expect(store.hasMore).toBe(false)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('shares one status fetch across header consumers and uses the global count', async () => {
|
||||
const desktop = useAnnouncementStore()
|
||||
const mobile = useAnnouncementStore()
|
||||
expect(mobile).toBe(desktop)
|
||||
desktop.resetSession('alice')
|
||||
const count = deferred<{ unread_count: number }>()
|
||||
const required = deferred<AnnouncementListResponse>()
|
||||
api.getUnreadCount.mockReturnValue(count.promise)
|
||||
api.getRequiredUnreadAnnouncements.mockReturnValue(required.promise)
|
||||
|
||||
const first = desktop.refreshStatus()
|
||||
const second = mobile.refreshStatus()
|
||||
expect(api.getUnreadCount).toHaveBeenCalledTimes(1)
|
||||
expect(api.getRequiredUnreadAnnouncements).toHaveBeenCalledTimes(1)
|
||||
count.resolve({ unread_count: 87 })
|
||||
required.resolve(page([notice('required', { requires_ack: true }), notice('normal')], 2, 87))
|
||||
await Promise.all([first, second])
|
||||
expect(desktop.unreadCount).toBe(87)
|
||||
expect(mobile.requiredItems.map(item => item.id)).toEqual(['required'])
|
||||
expect(desktop.items).toEqual([])
|
||||
|
||||
api.getUserAnnouncements.mockResolvedValue(page([notice('first-page-item')], 100, 87))
|
||||
await mobile.loadList()
|
||||
expect(desktop.unreadCount).toBe(87)
|
||||
expect(desktop.items).toHaveLength(1)
|
||||
expect(desktop.total).toBe(100)
|
||||
expect(desktop.hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('loads further pages using loaded offsets while retaining server totals and unread counts', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
const items = Array.from({ length: 45 }, (_, index) => notice(`notice-${index}`))
|
||||
api.getUserAnnouncements
|
||||
.mockResolvedValueOnce(page(items.slice(0, 20), 45, 38))
|
||||
.mockResolvedValueOnce(page(items.slice(20, 40), 45, 37))
|
||||
.mockResolvedValueOnce(page(items.slice(40), 45, 36))
|
||||
|
||||
await store.loadList()
|
||||
expect(store.hasMore).toBe(true)
|
||||
await store.loadList(true)
|
||||
expect(store.items).toHaveLength(40)
|
||||
expect(store.total).toBe(45)
|
||||
expect(store.unreadCount).toBe(37)
|
||||
await store.loadList(true)
|
||||
expect(store.items.map(item => item.id)).toEqual(items.map(item => item.id))
|
||||
expect(store.unreadCount).toBe(36)
|
||||
expect(store.hasMore).toBe(false)
|
||||
await store.loadList(true)
|
||||
expect(api.getUserAnnouncements.mock.calls.map(([query]) => query)).toEqual([
|
||||
{ limit: 20, offset: 0 },
|
||||
{ limit: 20, offset: 20 },
|
||||
{ limit: 20, offset: 40 },
|
||||
])
|
||||
})
|
||||
|
||||
it('advances by consumed page rows even when later pages repeat announcements', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
const items = Array.from({ length: 39 }, (_, index) => notice(`notice-${index}`))
|
||||
api.getUserAnnouncements
|
||||
.mockResolvedValueOnce(page(items.slice(0, 20), 41, 35))
|
||||
.mockResolvedValueOnce(page([items[18]!, items[19]!, ...items.slice(20, 38)], 41, 34))
|
||||
.mockResolvedValueOnce(page(items.slice(38), 41, 33))
|
||||
|
||||
await store.loadList()
|
||||
await store.loadList(true)
|
||||
expect(store.items).toHaveLength(38)
|
||||
expect(store.hasMore).toBe(true)
|
||||
await store.loadList(true)
|
||||
|
||||
expect(api.getUserAnnouncements.mock.calls.map(([query]) => query.offset)).toEqual([0, 20, 40])
|
||||
expect(store.items.map(item => item.id)).toEqual(items.map(item => item.id))
|
||||
expect(store.total).toBe(41)
|
||||
expect(store.unreadCount).toBe(33)
|
||||
expect(store.hasMore).toBe(false)
|
||||
await store.loadList(true)
|
||||
expect(api.getUserAnnouncements).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('retains loaded rows and counts when load-more fails, then retries the same offset', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
api.getUserAnnouncements.mockResolvedValueOnce(page([notice('first')], 2, 2))
|
||||
await store.loadList()
|
||||
api.getUserAnnouncements.mockRejectedValueOnce(new Error('unavailable'))
|
||||
await store.loadList(true)
|
||||
expect(store.items.map(item => item.id)).toEqual(['first'])
|
||||
expect(store.total).toBe(2)
|
||||
expect(store.unreadCount).toBe(2)
|
||||
expect(store.hasMore).toBe(true)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeTruthy()
|
||||
api.getUserAnnouncements.mockResolvedValueOnce(page([notice('second')], 2, 2))
|
||||
await store.loadList(true)
|
||||
expect(api.getUserAnnouncements).toHaveBeenLastCalledWith({ limit: 20, offset: 1 })
|
||||
expect(store.items.map(item => item.id)).toEqual(['first', 'second'])
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('retains prior status when polling fails and exposes the failure', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
api.getUnreadCount.mockResolvedValueOnce({ unread_count: 30 })
|
||||
api.getRequiredUnreadAnnouncements.mockResolvedValueOnce(page([notice('required', { requires_ack: true })], 1, 30))
|
||||
await store.refreshStatus()
|
||||
api.getUnreadCount.mockRejectedValueOnce(new Error('unavailable'))
|
||||
api.getRequiredUnreadAnnouncements.mockRejectedValueOnce(new Error('unavailable'))
|
||||
await store.refreshStatus()
|
||||
expect(store.unreadCount).toBe(30)
|
||||
expect(store.requiredItems.map(item => item.id)).toEqual(['required'])
|
||||
expect(store.error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('announcement acknowledgements', () => {
|
||||
it('updates list and badge while refilling a required queue larger than twenty', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
const all = Array.from({ length: 23 }, (_, index) => notice(`required-${index}`, { requires_ack: true }))
|
||||
const acknowledged = new Set<string>()
|
||||
const unread = () => all.filter(item => !acknowledged.has(item.id))
|
||||
api.getUserAnnouncements.mockImplementation(async () => page(all.slice(0, 20), 23, unread().length))
|
||||
api.getUnreadCount.mockImplementation(async () => ({ unread_count: unread().length }))
|
||||
api.getRequiredUnreadAnnouncements.mockImplementation(async () => page(unread().slice(0, 20), Math.min(unread().length, 20), unread().length))
|
||||
api.markAsRead.mockImplementation(async (id: string) => { acknowledged.add(id); return { message: 'read' } })
|
||||
|
||||
await Promise.all([store.loadList(), store.refreshStatus()])
|
||||
expect(store.requiredItems).toHaveLength(20)
|
||||
for (const [index, item] of all.entries()) {
|
||||
expect(store.requiredItems[0]?.id).toBe(item.id)
|
||||
await store.markRead(item.id)
|
||||
expect(store.unreadCount).toBe(22 - index)
|
||||
expect(store.requiredItems.some(required => required.id === item.id)).toBe(false)
|
||||
}
|
||||
expect(store.requiredItems).toEqual([])
|
||||
expect(store.items.every(item => item.is_read)).toBe(true)
|
||||
expect(api.markAsRead.mock.calls.map(([id]) => id)).toEqual(all.map(item => item.id))
|
||||
expect(api.getRequiredUnreadAnnouncements).toHaveBeenCalledTimes(24)
|
||||
})
|
||||
|
||||
it('deduplicates one pending acknowledgement and leaves unread state on failure', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
const item = notice('required', { requires_ack: true })
|
||||
store.items = [item]
|
||||
store.requiredItems = [item]
|
||||
store.unreadCount = 7
|
||||
const write = deferred<{ message: string }>()
|
||||
api.markAsRead.mockReturnValueOnce(write.promise)
|
||||
const first = store.markRead(item.id)
|
||||
const second = store.markRead(item.id)
|
||||
const failures = Promise.allSettled([first, second])
|
||||
expect(api.markAsRead).toHaveBeenCalledTimes(1)
|
||||
expect(store.unreadCount).toBe(7)
|
||||
write.reject(new Error('write failed'))
|
||||
expect((await failures).every(result => result.status === 'rejected')).toBe(true)
|
||||
expect(store.items[0]?.is_read).toBe(false)
|
||||
expect(store.requiredItems.map(required => required.id)).toEqual([item.id])
|
||||
expect(store.unreadCount).toBe(7)
|
||||
expect(api.getUnreadCount).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not allow pre-acknowledgement list or status responses to resurrect an unread item', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
const item = notice('required', { requires_ack: true })
|
||||
store.items = [item]
|
||||
store.requiredItems = [item]
|
||||
store.unreadCount = 1
|
||||
const oldList = deferred<AnnouncementListResponse>()
|
||||
const oldCount = deferred<{ unread_count: number }>()
|
||||
const oldRequired = deferred<AnnouncementListResponse>()
|
||||
api.getUserAnnouncements.mockReturnValueOnce(oldList.promise)
|
||||
api.getUnreadCount.mockReturnValueOnce(oldCount.promise)
|
||||
api.getRequiredUnreadAnnouncements.mockReturnValueOnce(oldRequired.promise)
|
||||
const listRequest = store.loadList()
|
||||
const statusRequest = store.refreshStatus()
|
||||
|
||||
await store.markRead(item.id)
|
||||
expect(store.items[0]?.is_read).toBe(true)
|
||||
expect(store.unreadCount).toBe(0)
|
||||
oldList.resolve(page([item], 1, 1))
|
||||
oldCount.resolve({ unread_count: 1 })
|
||||
oldRequired.resolve(page([item], 1, 1))
|
||||
await Promise.all([listRequest, statusRequest])
|
||||
expect(store.items[0]?.is_read).toBe(true)
|
||||
expect(store.requiredItems).toEqual([])
|
||||
expect(store.unreadCount).toBe(0)
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('only clears all reads after a successful write and preserves state after a rejected write', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
const item = notice('required', { requires_ack: true })
|
||||
store.items = [item]
|
||||
store.requiredItems = [item]
|
||||
store.unreadCount = 60
|
||||
const rejected = deferred<{ message: string }>()
|
||||
api.markAllAsRead.mockReturnValueOnce(rejected.promise)
|
||||
const failed = expect(store.markAllRead()).rejects.toThrow('write failed')
|
||||
await store.markAllRead()
|
||||
expect(api.markAllAsRead).toHaveBeenCalledTimes(1)
|
||||
expect(store.markingAll).toBe(true)
|
||||
expect(store.unreadCount).toBe(60)
|
||||
rejected.reject(new Error('write failed'))
|
||||
await failed
|
||||
expect(store.markingAll).toBe(false)
|
||||
expect(store.unreadCount).toBe(60)
|
||||
expect(store.items[0]?.is_read).toBe(false)
|
||||
expect(store.requiredItems).toHaveLength(1)
|
||||
|
||||
api.getUnreadCount.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
api.getRequiredUnreadAnnouncements.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
await store.markAllRead()
|
||||
expect(store.items[0]?.is_read).toBe(true)
|
||||
expect(store.requiredItems).toEqual([])
|
||||
expect(store.unreadCount).toBe(0)
|
||||
expect(store.markingAll).toBe(false)
|
||||
expect(store.error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('announcement session isolation', () => {
|
||||
it('ignores old account list and status responses even after switching back to that account', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
const oldList = deferred<AnnouncementListResponse>()
|
||||
const oldCount = deferred<{ unread_count: number }>()
|
||||
const oldRequired = deferred<AnnouncementListResponse>()
|
||||
api.getUserAnnouncements.mockReturnValueOnce(oldList.promise)
|
||||
api.getUnreadCount.mockReturnValueOnce(oldCount.promise)
|
||||
api.getRequiredUnreadAnnouncements.mockReturnValueOnce(oldRequired.promise)
|
||||
const listRequest = store.loadList()
|
||||
const statusRequest = store.refreshStatus()
|
||||
store.resetSession('bob')
|
||||
store.resetSession('alice')
|
||||
const current = notice('new-session-notice', { requires_ack: true })
|
||||
api.getUserAnnouncements.mockResolvedValueOnce(page([current], 1, 1))
|
||||
api.getUnreadCount.mockResolvedValueOnce({ unread_count: 1 })
|
||||
api.getRequiredUnreadAnnouncements.mockResolvedValueOnce(page([current], 1, 1))
|
||||
await Promise.all([store.loadList(), store.refreshStatus()])
|
||||
|
||||
oldList.resolve(page([notice('old-secret')], 99, 90))
|
||||
oldCount.resolve({ unread_count: 90 })
|
||||
oldRequired.resolve(page([notice('old-required', { requires_ack: true })], 1, 90))
|
||||
await Promise.all([listRequest, statusRequest])
|
||||
expect(store.items.map(item => item.id)).toEqual([current.id])
|
||||
expect(store.requiredItems.map(item => item.id)).toEqual([current.id])
|
||||
expect(store.total).toBe(1)
|
||||
expect(store.unreadCount).toBe(1)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['one', 'all'] as const)('ignores a late %s acknowledgement from a previous session', async operation => {
|
||||
const store = useAnnouncementStore()
|
||||
store.resetSession('alice')
|
||||
const item = notice('same-notice', { requires_ack: true })
|
||||
store.items = [item]
|
||||
store.requiredItems = [item]
|
||||
store.unreadCount = 1
|
||||
const write = deferred<{ message: string }>()
|
||||
if (operation === 'one') api.markAsRead.mockReturnValueOnce(write.promise)
|
||||
else api.markAllAsRead.mockReturnValueOnce(write.promise)
|
||||
const request = operation === 'one' ? store.markRead(item.id) : store.markAllRead()
|
||||
store.resetSession('bob')
|
||||
store.items = [{ ...item }]
|
||||
store.requiredItems = [{ ...item }]
|
||||
store.unreadCount = 5
|
||||
write.resolve({ message: 'read' })
|
||||
await request
|
||||
expect(store.items[0]?.is_read).toBe(false)
|
||||
expect(store.requiredItems.map(required => required.id)).toEqual([item.id])
|
||||
expect(store.unreadCount).toBe(5)
|
||||
expect(store.markingAll).toBe(false)
|
||||
expect(api.getUnreadCount).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import { getI18nLocale } from '@/i18n'
|
||||
|
||||
export const useAnnouncementStore = defineStore('announcements', () => {
|
||||
const items = ref<Announcement[]>([])
|
||||
const requiredItems = ref<Announcement[]>([])
|
||||
const unreadCount = ref<number | null>(null)
|
||||
const total = ref(0)
|
||||
const nextOffset = ref(0)
|
||||
const loading = ref(false)
|
||||
const markingAll = ref(false)
|
||||
const listError = ref<string | null>(null)
|
||||
const statusError = ref<string | null>(null)
|
||||
const error = computed(() => listError.value || statusError.value)
|
||||
const hasMore = computed(() => nextOffset.value < total.value)
|
||||
let sessionId: string | null = null
|
||||
let sessionVersion = 0
|
||||
let revision = 0
|
||||
let listRequestId = 0
|
||||
let listLoaded = false
|
||||
let statusPromise: Promise<void> | null = null
|
||||
const reads = new Map<string, Promise<void>>()
|
||||
const message = (zh: string, en: string) => getI18nLocale() === 'zh-CN' ? zh : en
|
||||
|
||||
function resetSession(userId: string | null) {
|
||||
sessionId = userId
|
||||
sessionVersion += 1
|
||||
revision += 1
|
||||
listRequestId += 1
|
||||
statusPromise = null
|
||||
reads.clear()
|
||||
items.value = []
|
||||
requiredItems.value = []
|
||||
unreadCount.value = null
|
||||
total.value = 0
|
||||
nextOffset.value = 0
|
||||
loading.value = false
|
||||
markingAll.value = false
|
||||
listError.value = null
|
||||
statusError.value = null
|
||||
listLoaded = false
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
if (!sessionId) return
|
||||
if (statusPromise) return statusPromise
|
||||
const version = revision
|
||||
const request = (async () => {
|
||||
const [count, required] = await Promise.allSettled([
|
||||
announcementApi.getUnreadCount(),
|
||||
announcementApi.getRequiredUnreadAnnouncements(),
|
||||
])
|
||||
if (version !== revision) return
|
||||
if (count.status === 'fulfilled') unreadCount.value = count.value.unread_count
|
||||
if (required.status === 'fulfilled') {
|
||||
requiredItems.value = required.value.items.filter(item => item.requires_ack && !item.is_read)
|
||||
}
|
||||
statusError.value = count.status === 'rejected' || required.status === 'rejected'
|
||||
? message('公告暂时无法更新', 'Announcements could not be refreshed') : null
|
||||
})()
|
||||
statusPromise = request
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
if (statusPromise === request) statusPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadList(more = false) {
|
||||
if (!sessionId || (more && (loading.value || !hasMore.value))) return
|
||||
const requestId = ++listRequestId
|
||||
const version = revision
|
||||
const offset = more ? nextOffset.value : 0
|
||||
loading.value = true
|
||||
listError.value = null
|
||||
try {
|
||||
const response = await announcementApi.getUserAnnouncements({ limit: 20, offset })
|
||||
if (requestId !== listRequestId || version !== revision) return
|
||||
items.value = more
|
||||
? Array.from(new Map([...items.value, ...response.items].map(item => [item.id, item])).values())
|
||||
: response.items
|
||||
total.value = response.total
|
||||
nextOffset.value = offset + response.items.length
|
||||
unreadCount.value = response.unread_count
|
||||
listLoaded = true
|
||||
} catch {
|
||||
if (requestId === listRequestId && version === revision) {
|
||||
listError.value = message('公告加载失败,请重试', 'Announcements could not be loaded. Please retry.')
|
||||
}
|
||||
} finally {
|
||||
if (requestId === listRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([refreshStatus(), listLoaded ? loadList() : Promise.resolve()])
|
||||
}
|
||||
|
||||
// Invalidate reads started before an acknowledgement or account switch.
|
||||
function invalidateReads() {
|
||||
revision += 1
|
||||
listRequestId += 1
|
||||
loading.value = false
|
||||
statusPromise = null
|
||||
}
|
||||
|
||||
async function markRead(id: string) {
|
||||
if (!sessionId) return
|
||||
const pending = reads.get(id)
|
||||
if (pending) return pending
|
||||
const owner = sessionVersion
|
||||
const request = (async () => {
|
||||
await announcementApi.markAsRead(id)
|
||||
if (sessionVersion !== owner) return
|
||||
invalidateReads()
|
||||
const wasUnread = items.value.some(item => item.id === id && item.is_read === false)
|
||||
|| requiredItems.value.some(item => item.id === id)
|
||||
items.value = items.value.map(item => item.id === id ? { ...item, is_read: true } : item)
|
||||
requiredItems.value = requiredItems.value.filter(item => item.id !== id)
|
||||
if (wasUnread && unreadCount.value !== null) unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
await refreshStatus()
|
||||
})()
|
||||
reads.set(id, request)
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
if (reads.get(id) === request) reads.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
if (!sessionId || markingAll.value) return
|
||||
const owner = sessionVersion
|
||||
markingAll.value = true
|
||||
try {
|
||||
await announcementApi.markAllAsRead()
|
||||
if (sessionVersion !== owner) return
|
||||
invalidateReads()
|
||||
items.value = items.value.map(item => ({ ...item, is_read: true }))
|
||||
requiredItems.value = []
|
||||
unreadCount.value = 0
|
||||
await refreshStatus()
|
||||
} finally {
|
||||
if (sessionVersion === owner) markingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { items, requiredItems, unreadCount, total, loading, markingAll, error, hasMore,
|
||||
resetSession, refreshStatus, loadList, refresh, markRead, markAllRead }
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
export interface ActivityHeatmapDay {
|
||||
date: string
|
||||
requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_tokens?: number
|
||||
total_cost?: number
|
||||
actual_total_cost?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,19 @@ export function usagePolicyEntitlementLabels(policy: UsagePolicyEntitlement): st
|
||||
return labels.length > 0 ? labels : ['使用限制']
|
||||
}
|
||||
|
||||
export function billingEntitlementLabels(
|
||||
items: BillingEntitlement[] | undefined,
|
||||
translate: (value: string) => string,
|
||||
): string[] {
|
||||
return (items || []).flatMap((item) => {
|
||||
if (item.type === 'wallet_credit') return `${translate('附赠余额')} $${Number(item.amount_usd || 0).toFixed(2)}`
|
||||
if (item.type === 'daily_quota') return `${translate('每日额度')} $${Number(item.daily_quota_usd || 0).toFixed(2)}`
|
||||
if (item.type === 'membership_group') return translate('会员权益')
|
||||
if (item.type === 'usage_policy') return usagePolicyEntitlementLabels(item)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function formatUsagePolicyRuleLabel(rule: UsagePolicyRule): string {
|
||||
const limit = formatLimit(rule.limit)
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<Dashboard />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dashboard from '@/views/shared/Dashboard.vue'
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,325 +1,351 @@
|
||||
<template>
|
||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">
|
||||
成本分析
|
||||
</h1>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
成本趋势、预测与节省统计
|
||||
</p>
|
||||
</div>
|
||||
<TimeRangePicker v-model="timeRange" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<Card class="p-4 space-y-2">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
缓存节省
|
||||
</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ formatCurrency(costSavings?.cache_savings ?? 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
读取成本 {{ formatCurrency(costSavings?.cache_read_cost ?? 0) }}
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 space-y-2">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
缓存读取 Tokens
|
||||
</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
预计全额成本 {{ formatCurrency(costSavings?.estimated_full_cost ?? 0) }}
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 space-y-2">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
缓存创建成本
|
||||
</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
基于当前时间范围
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card class="p-4">
|
||||
<CostForecastChart
|
||||
title="成本趋势预测"
|
||||
:history="forecastHistory"
|
||||
:forecast="forecastFuture"
|
||||
:loading="forecastLoading"
|
||||
/>
|
||||
</Card>
|
||||
<QuotaProgressCard
|
||||
title="月卡消耗进度"
|
||||
:providers="quotaProviders"
|
||||
:loading="quotaLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LeaderboardTable
|
||||
title="API Key 用量排行"
|
||||
:items="apiKeyLeaderboard"
|
||||
:metric="apiKeyLeaderboardMetric"
|
||||
:loading="apiKeyLeaderboardLoading"
|
||||
:show-metric-select="false"
|
||||
@update:metric="apiKeyLeaderboardMetric = $event"
|
||||
<main class="space-y-6 px-4 pb-8 sm:px-6 lg:px-0">
|
||||
<OverviewToolbar
|
||||
:title="t('成本分析', 'Cost analysis')"
|
||||
:range="range"
|
||||
:preset="relativePreset"
|
||||
:refresh-active="autoRefresh"
|
||||
:refresh-title="refreshTitle"
|
||||
presets-only
|
||||
:loading="loading"
|
||||
@update:range="setRange"
|
||||
@refresh="toggleAutoRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<LeaderboardControls
|
||||
:metric="apiKeyLeaderboardMetric"
|
||||
:time-range="apiKeyLeaderboardTimeRange"
|
||||
@update:metric="apiKeyLeaderboardMetric = $event"
|
||||
@update:time-range="apiKeyLeaderboardTimeRange = $event"
|
||||
/>
|
||||
</template>
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="apiKeyLeaderboardTotal > 0"
|
||||
:current="apiKeyLeaderboardPage"
|
||||
:total="apiKeyLeaderboardTotal"
|
||||
:page-size="apiKeyLeaderboardPageSize"
|
||||
:page-size-options="apiKeyLeaderboardPageSizeOptions"
|
||||
@update:current="apiKeyLeaderboardPage = $event"
|
||||
@update:page-size="apiKeyLeaderboardPageSize = $event"
|
||||
/>
|
||||
</template>
|
||||
</LeaderboardTable>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!accounts?.items.length"
|
||||
@click="recording = true"
|
||||
>
|
||||
<Plus class="mr-1.5 h-3.5 w-3.5" />{{ t('登记支出', 'Record expense') }}
|
||||
</Button>
|
||||
</OverviewToolbar>
|
||||
<div
|
||||
class="grid grid-cols-2 gap-3 xl:grid-cols-4"
|
||||
data-cost-summary
|
||||
>
|
||||
<Card class="min-w-0 space-y-2 p-4 sm:p-5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('已登记支出', 'Recorded expenses') }}
|
||||
</p>
|
||||
<div class="min-h-8 text-xl font-semibold tabular-nums">
|
||||
<p
|
||||
v-for="item in totalAmounts('amount')"
|
||||
:key="item.currency"
|
||||
>
|
||||
{{ item.text }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('所选时段', 'Selected range') }} · {{ count(expenses?.total) }} {{ t('笔', 'entries') }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="min-w-0 space-y-2 p-4 sm:p-5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('提供商充值', 'Provider recharges') }}
|
||||
</p>
|
||||
<div class="min-h-8 text-xl font-semibold tabular-nums">
|
||||
<p
|
||||
v-for="item in totalAmounts('recharge_amount')"
|
||||
:key="item.currency"
|
||||
>
|
||||
{{ item.text }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('已登记的余额充值', 'Recorded balance top-ups') }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="min-w-0 space-y-2 p-4 sm:p-5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('套餐与续费', 'Subscriptions and renewals') }}
|
||||
</p>
|
||||
<div class="min-h-8 text-xl font-semibold tabular-nums">
|
||||
<p
|
||||
v-for="item in totalAmounts('subscription_amount')"
|
||||
:key="item.currency"
|
||||
>
|
||||
{{ item.text }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('已登记的套餐实付金额', 'Recorded subscription payments') }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="min-w-0 space-y-2 p-4 sm:p-5">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('提供商调用', 'Provider requests') }}
|
||||
</p>
|
||||
<p class="min-h-8 text-xl font-semibold tabular-nums">
|
||||
{{ count(costs?.data.summary.request_count) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ count(costs?.data.summary.total_tokens) }} Tokens
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('支出以登记的付款记录为准,不同币种分别汇总;套餐额度与上游余额单独展示。', 'Expenses reflect recorded payments, grouped by currency. Plan quotas and upstream balances are shown separately.') }}
|
||||
</p>
|
||||
|
||||
<UsageProviderTable
|
||||
:data="providerStats"
|
||||
:is-admin="true"
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-provider-usage
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('提供商用量', 'Provider usage') }}
|
||||
</h2><p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('所选时段 · 按请求量排序', 'Selected range · Most requests first') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="exporting || !providers?.data.total"
|
||||
@click="exportCsv('breakdown', providerQuery)"
|
||||
>
|
||||
<Download class="mr-1.5 h-3.5 w-3.5" />{{ t('导出用量报表', 'Export usage') }}
|
||||
</Button>
|
||||
</div>
|
||||
<OverviewStatus
|
||||
:loading="providerLoading && !providers"
|
||||
:error="providerError || exportError"
|
||||
@retry="refreshProviders"
|
||||
/>
|
||||
<Card class="min-w-0 overflow-hidden">
|
||||
<div class="overflow-x-auto px-4 sm:px-5">
|
||||
<table class="w-full min-w-[760px] text-sm">
|
||||
<thead class="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th class="py-3 text-left font-medium">
|
||||
{{ t('提供商', 'Provider') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('请求量', 'Requests') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('输入 Token', 'Input tokens') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('输出 Token', 'Output tokens') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('已登记支出', 'Recorded expenses') }}
|
||||
</th>
|
||||
<th class="py-3 text-right font-medium">
|
||||
{{ t('使用记录', 'Usage records') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr
|
||||
v-for="provider in providers?.data.items || []"
|
||||
:key="provider.id || 'unattributed'"
|
||||
>
|
||||
<td class="max-w-64 break-words py-4 font-medium">
|
||||
{{ provider.label || t('未归属提供商', 'Unattributed provider') }}<p class="mt-1 text-xs font-normal text-muted-foreground">
|
||||
{{ providerType(provider.id) }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-right tabular-nums">
|
||||
{{ count(provider.request_count) }}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-right tabular-nums">
|
||||
{{ count(provider.input_tokens) }}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-right tabular-nums">
|
||||
{{ count(provider.output_tokens) }}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-right tabular-nums">
|
||||
<p
|
||||
v-for="item in providerAmounts(provider.id)"
|
||||
:key="item.currency"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ item.text }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="py-4 text-right">
|
||||
<RouterLink
|
||||
v-if="provider.id"
|
||||
:to="link('/admin/usage', { provider_id: provider.id })"
|
||||
class="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ t('查看', 'View') }}<ArrowUpRight class="h-3.5 w-3.5" />
|
||||
</RouterLink><span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!providers?.data.items.length">
|
||||
<td
|
||||
colspan="6"
|
||||
class="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
{{ providerLoading ? t('加载提供商用量…', 'Loading provider usage…') : providers ? t('此时段暂无调用记录', 'No requests in this range') : t('暂未取得用量', 'Usage unavailable') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="providers"
|
||||
class="px-4 pb-3 sm:px-5"
|
||||
>
|
||||
<OverviewPagination
|
||||
:total="providers.data.total"
|
||||
:limit="providerLimit"
|
||||
:offset="providerOffset"
|
||||
:loading="providerLoading"
|
||||
@page="value => providerOffset = value"
|
||||
@size="value => { providerLimit = value; providerOffset = 0 }"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<OverviewStatus
|
||||
:loading="accountLoading && !accounts"
|
||||
:error="accountError"
|
||||
@retry="refreshAccounts"
|
||||
/>
|
||||
</div>
|
||||
<ProviderAccounts
|
||||
v-if="accounts"
|
||||
:accounts="accounts.items"
|
||||
:observed-at="accounts.observed_at"
|
||||
:timezone="range.timezone"
|
||||
/>
|
||||
|
||||
<OverviewStatus
|
||||
:loading="expenseLoading && !expenses"
|
||||
:error="expenseError"
|
||||
@retry="refreshExpenses"
|
||||
/>
|
||||
<ExpenseLedger
|
||||
:data="expenses"
|
||||
:range="range"
|
||||
:offset="expenseOffset"
|
||||
:loading="expenseLoading"
|
||||
:can-record="Boolean(accounts?.items.length)"
|
||||
@record="recording = true"
|
||||
@page="selectExpensePage"
|
||||
@size="value => { expenseLimit = value; expenseOffset = 0 }"
|
||||
@changed="expensesChanged"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="space-y-4"
|
||||
data-cost-reports
|
||||
>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('用量报表', 'Usage reports') }}
|
||||
</h2>
|
||||
<OverviewStatus
|
||||
:loading="costLoading && !costs"
|
||||
:error="costError"
|
||||
@retry="refreshCosts"
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card class="min-w-0 space-y-4 p-4 sm:p-5">
|
||||
<h3 class="text-sm font-medium">
|
||||
{{ t('调用趋势', 'Request trend') }}
|
||||
</h3><div
|
||||
v-if="hasRequests"
|
||||
class="h-60"
|
||||
>
|
||||
<LineChart :data="requestChart" />
|
||||
</div><p
|
||||
v-else
|
||||
class="flex h-60 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ costs ? t('此时段暂无调用', 'No requests in this range') : t('暂无报表数据', 'Report unavailable') }}
|
||||
</p>
|
||||
</Card>
|
||||
<Card class="min-w-0 space-y-4 p-4 sm:p-5">
|
||||
<h3 class="text-sm font-medium">
|
||||
{{ t('Token 用量趋势', 'Token usage trend') }}
|
||||
</h3><div
|
||||
v-if="hasRequests"
|
||||
class="h-60"
|
||||
>
|
||||
<LineChart :data="tokenChart" />
|
||||
</div><p
|
||||
v-else
|
||||
class="flex h-60 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ costs ? t('此时段暂无用量', 'No usage in this range') : t('暂无报表数据', 'Report unavailable') }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
<ExpenseDialog
|
||||
v-model:open="recording"
|
||||
:providers="accounts?.items || []"
|
||||
:timezone="range.timezone"
|
||||
@saved="expensesChanged"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { Pagination } from '@/components/ui'
|
||||
import { TimeRangePicker } from '@/components/common'
|
||||
import { CostForecastChart, LeaderboardControls, LeaderboardTable, QuotaProgressCard } from '@/components/stats'
|
||||
import { UsageProviderTable } from '@/features/usage/components'
|
||||
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type LeaderboardItem, type QuotaUsageProvider } from '@/api/admin'
|
||||
import { usageApi } from '@/api/usage'
|
||||
import { formatCurrency, formatTokens } from '@/utils/format'
|
||||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||
import { normalizeUsageProviderStats } from '@/features/usage/utils/providerStats'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
import type { ProviderStatsItem } from '@/features/usage/types'
|
||||
|
||||
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
|
||||
|
||||
const forecast = ref<CostForecastResponse | null>(null)
|
||||
const costSavings = ref<CostSavingsResponse | null>(null)
|
||||
const quotaProviders = ref<QuotaUsageProvider[]>([])
|
||||
const providerStats = ref<ProviderStatsItem[]>([])
|
||||
const apiKeyLeaderboard = ref<LeaderboardItem[]>([])
|
||||
const apiKeyLeaderboardMetric = ref<'requests' | 'tokens' | 'cost'>('cost')
|
||||
const apiKeyLeaderboardTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
|
||||
const apiKeyLeaderboardPage = ref(1)
|
||||
const apiKeyLeaderboardPageSize = ref(10)
|
||||
const apiKeyLeaderboardTotal = ref(0)
|
||||
const apiKeyLeaderboardPageSizeOptions = [10, 20, 50, 100]
|
||||
|
||||
const forecastLoading = ref(false)
|
||||
const quotaLoading = ref(false)
|
||||
const apiKeyLeaderboardLoading = ref(false)
|
||||
let forecastRequestId = 0
|
||||
let savingsRequestId = 0
|
||||
let quotaRequestId = 0
|
||||
let providerStatsRequestId = 0
|
||||
let apiKeyLeaderboardRequestId = 0
|
||||
let loadAllPromise: Promise<void> | null = null
|
||||
let hasPendingLoadAll = false
|
||||
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let apiKeyLeaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const forecastHistory = computed(() => forecast.value?.history || [])
|
||||
const forecastFuture = computed(() => forecast.value?.forecast || [])
|
||||
|
||||
function buildTimeRangeParams() {
|
||||
return {
|
||||
start_date: timeRange.value.start_date,
|
||||
end_date: timeRange.value.end_date,
|
||||
preset: timeRange.value.preset,
|
||||
timezone: timeRange.value.timezone,
|
||||
tz_offset_minutes: timeRange.value.tz_offset_minutes
|
||||
}
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowUpRight, Download, Plus } from 'lucide-vue-next'
|
||||
import { Button, Card } from '@/components/ui'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import { overviewApi } from '@/api/overview'
|
||||
import { providerFinanceApi, type ProviderExpenseTotals } from '@/api/providerFinance'
|
||||
import OverviewToolbar from '@/features/overview/components/OverviewToolbar.vue'
|
||||
import OverviewStatus from '@/features/overview/components/OverviewStatus.vue'
|
||||
import OverviewPagination from '@/features/overview/components/OverviewPagination.vue'
|
||||
import ExpenseDialog from '@/features/overview/costs/ExpenseDialog.vue'
|
||||
import ExpenseLedger from '@/features/overview/costs/ExpenseLedger.vue'
|
||||
import ProviderAccounts from '@/features/overview/costs/ProviderAccounts.vue'
|
||||
import { useOverviewQuery } from '@/features/overview/query'
|
||||
import { useOverviewAutoRefresh } from '@/features/overview/useOverviewAutoRefresh'
|
||||
import { useOverviewRequest } from '@/features/overview/useOverviewRequest'
|
||||
import { useOverviewExport } from '@/features/overview/useOverviewExport'
|
||||
import { useOverviewI18n } from '@/features/overview/i18n'
|
||||
import { count, money, timestamp } from '@/features/overview/format'
|
||||
const { t } = useOverviewI18n()
|
||||
const { range, relativePreset, refreshRange, setRange, link } = useOverviewQuery('last30days', { rolling: true })
|
||||
const { revision, autoRefresh, refreshTitle, toggleAutoRefresh } = useOverviewAutoRefresh(refreshRange)
|
||||
const recording = ref(false)
|
||||
const providerOffset = ref(0)
|
||||
const expenseOffset = ref(0)
|
||||
const providerLimit = ref(25)
|
||||
const expenseLimit = ref(25)
|
||||
const selectedScope = computed(() => JSON.stringify(relativePreset.value ? { preset: relativePreset.value, timezone: range.value.timezone } : range.value))
|
||||
watch(selectedScope, () => { providerOffset.value = 0; expenseOffset.value = 0 })
|
||||
const costQuery = computed(() => ({ ...range.value, granularity: 'day' as const }))
|
||||
const providerQuery = computed(() => ({ ...range.value, group_by: 'provider', sort: 'request_count', order: 'desc' as const, limit: providerLimit.value, offset: providerOffset.value }))
|
||||
const expenseQuery = computed(() => ({ ...range.value, limit: expenseLimit.value, offset: expenseOffset.value }))
|
||||
const { data: costs, loading: costLoading, error: costError, refresh: refreshCosts } = useOverviewRequest(() => JSON.stringify([costQuery.value, revision.value]), signal => overviewApi.costs(costQuery.value, signal), { scopeKey: selectedScope })
|
||||
const { data: providers, loading: providerLoading, error: providerError, refresh: refreshProviders } = useOverviewRequest(() => JSON.stringify([providerQuery.value, revision.value]), signal => overviewApi.breakdown(providerQuery.value, signal), { scopeKey: () => JSON.stringify([selectedScope.value, providerOffset.value]) })
|
||||
const { data: accounts, loading: accountLoading, error: accountError, refresh: refreshAccounts } = useOverviewRequest(revision, signal => providerFinanceApi.accounts(signal))
|
||||
const { data: expenses, loading: expenseLoading, error: expenseError, refresh: refreshExpenses } = useOverviewRequest(() => JSON.stringify([expenseQuery.value, revision.value]), signal => providerFinanceApi.expenses(expenseQuery.value, signal), { scopeKey: selectedScope })
|
||||
const { exporting, exportError, exportCsv } = useOverviewExport()
|
||||
const loading = computed(() => costLoading.value || providerLoading.value || accountLoading.value || expenseLoading.value)
|
||||
function expensesChanged() { if (expenseOffset.value) expenseOffset.value = 0; else void refreshExpenses() }
|
||||
function selectExpensePage(offset: number) { if (expenseOffset.value === offset) void refreshExpenses(); else expenseOffset.value = offset }
|
||||
function amount(value: string, currency: string) { return money({ value, currency, basis: 'provider_expense', status: 'known' }) }
|
||||
function totalAmounts(key: keyof Pick<ProviderExpenseTotals, 'amount' | 'recharge_amount' | 'subscription_amount'>) {
|
||||
if (!expenses.value) return [{ currency: '', text: '-' }]
|
||||
if (!expenses.value.totals.length) return [{ currency: '', text: t('暂无记录', 'No records') }]
|
||||
return expenses.value.totals.map(row => ({ currency: row.currency, text: `${amount(row[key], row.currency)} ${row.currency}` }))
|
||||
}
|
||||
|
||||
async function loadForecast() {
|
||||
const requestId = ++forecastRequestId
|
||||
forecastLoading.value = true
|
||||
try {
|
||||
const data = await adminApi.getCostForecast(buildTimeRangeParams())
|
||||
if (requestId !== forecastRequestId) return
|
||||
forecast.value = data
|
||||
} finally {
|
||||
if (requestId === forecastRequestId) {
|
||||
forecastLoading.value = false
|
||||
}
|
||||
}
|
||||
function providerAmounts(id: string | null) {
|
||||
if (!expenses.value || !id) return [{ currency: '', text: '-' }]
|
||||
const amounts = expenses.value.providers.filter(item => item.provider_id === id)
|
||||
return amounts.length ? amounts.map(item => ({ currency: item.currency, text: `${amount(item.amount, item.currency)} ${item.currency}` })) : [{ currency: '', text: t('未登记', 'Unrecorded') }]
|
||||
}
|
||||
|
||||
async function loadSavings() {
|
||||
const requestId = ++savingsRequestId
|
||||
const data = await adminApi.getCostSavings(buildTimeRangeParams())
|
||||
if (requestId !== savingsRequestId) return
|
||||
costSavings.value = data
|
||||
function providerType(id: string | null) {
|
||||
const type = accounts.value?.items.find(account => account.provider_id === id)?.billing_type
|
||||
return type === 'monthly_quota' ? t('周期套餐', 'Period plan') : type === 'free_tier' ? t('免费额度', 'Free tier') : type === 'pay_as_you_go' ? t('按量付费', 'Pay as you go') : ''
|
||||
}
|
||||
|
||||
async function loadQuotaUsage() {
|
||||
const requestId = ++quotaRequestId
|
||||
quotaLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getQuotaUsage()
|
||||
if (requestId !== quotaRequestId) return
|
||||
quotaProviders.value = response.providers
|
||||
} finally {
|
||||
if (requestId === quotaRequestId) {
|
||||
quotaLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderStats() {
|
||||
const requestId = ++providerStatsRequestId
|
||||
const stats = await usageApi.getUsageByProvider({
|
||||
...buildTimeRangeParams(),
|
||||
limit: 8
|
||||
})
|
||||
if (requestId !== providerStatsRequestId) return
|
||||
providerStats.value = normalizeUsageProviderStats(stats)
|
||||
}
|
||||
|
||||
async function loadApiKeyLeaderboard() {
|
||||
const requestId = ++apiKeyLeaderboardRequestId
|
||||
apiKeyLeaderboardLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getLeaderboardApiKeys({
|
||||
...buildApiKeyLeaderboardTimeRangeParams(),
|
||||
metric: apiKeyLeaderboardMetric.value,
|
||||
order: 'desc',
|
||||
limit: apiKeyLeaderboardPageSize.value,
|
||||
offset: (apiKeyLeaderboardPage.value - 1) * apiKeyLeaderboardPageSize.value,
|
||||
include_inactive: false,
|
||||
exclude_admin: false
|
||||
})
|
||||
if (requestId !== apiKeyLeaderboardRequestId) return
|
||||
apiKeyLeaderboard.value = response.items
|
||||
apiKeyLeaderboardTotal.value = response.total
|
||||
if (response.items.length === 0 && response.total > 0 && apiKeyLeaderboardPage.value > 1) {
|
||||
apiKeyLeaderboardPage.value = 1
|
||||
scheduleApiKeyLeaderboardLoad()
|
||||
}
|
||||
} finally {
|
||||
if (requestId === apiKeyLeaderboardRequestId) {
|
||||
apiKeyLeaderboardLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildApiKeyLeaderboardTimeRangeParams() {
|
||||
return {
|
||||
start_date: apiKeyLeaderboardTimeRange.value.start_date,
|
||||
end_date: apiKeyLeaderboardTimeRange.value.end_date,
|
||||
preset: apiKeyLeaderboardTimeRange.value.preset,
|
||||
timezone: apiKeyLeaderboardTimeRange.value.timezone,
|
||||
tz_offset_minutes: apiKeyLeaderboardTimeRange.value.tz_offset_minutes
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (loadAllPromise) {
|
||||
hasPendingLoadAll = true
|
||||
return loadAllPromise
|
||||
}
|
||||
loadAllPromise = Promise.all([
|
||||
loadForecast(),
|
||||
loadSavings(),
|
||||
loadQuotaUsage(),
|
||||
loadProviderStats(),
|
||||
loadApiKeyLeaderboard()
|
||||
])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
loadAllPromise = null
|
||||
if (hasPendingLoadAll) {
|
||||
hasPendingLoadAll = false
|
||||
void loadAll()
|
||||
}
|
||||
})
|
||||
return loadAllPromise
|
||||
}
|
||||
|
||||
function scheduleLoadAll() {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
}
|
||||
loadAllDebounceTimer = setTimeout(() => {
|
||||
loadAllDebounceTimer = null
|
||||
void loadAll()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function scheduleApiKeyLeaderboardLoad() {
|
||||
if (apiKeyLeaderboardDebounceTimer) {
|
||||
clearTimeout(apiKeyLeaderboardDebounceTimer)
|
||||
}
|
||||
apiKeyLeaderboardDebounceTimer = setTimeout(() => {
|
||||
apiKeyLeaderboardDebounceTimer = null
|
||||
void loadApiKeyLeaderboard()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function resetApiKeyLeaderboardPage() {
|
||||
if (apiKeyLeaderboardPage.value === 1) {
|
||||
return
|
||||
}
|
||||
apiKeyLeaderboardPage.value = 1
|
||||
}
|
||||
|
||||
watch(timeRange, () => {
|
||||
resetApiKeyLeaderboardPage()
|
||||
scheduleLoadAll()
|
||||
}, { deep: true })
|
||||
watch(apiKeyLeaderboardMetric, () => {
|
||||
resetApiKeyLeaderboardPage()
|
||||
scheduleApiKeyLeaderboardLoad()
|
||||
})
|
||||
watch(apiKeyLeaderboardTimeRange, () => {
|
||||
resetApiKeyLeaderboardPage()
|
||||
scheduleApiKeyLeaderboardLoad()
|
||||
}, { deep: true })
|
||||
watch([apiKeyLeaderboardPage, apiKeyLeaderboardPageSize], scheduleApiKeyLeaderboardLoad)
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loadAllDebounceTimer) {
|
||||
clearTimeout(loadAllDebounceTimer)
|
||||
loadAllDebounceTimer = null
|
||||
}
|
||||
if (apiKeyLeaderboardDebounceTimer) {
|
||||
clearTimeout(apiKeyLeaderboardDebounceTimer)
|
||||
apiKeyLeaderboardDebounceTimer = null
|
||||
}
|
||||
hasPendingLoadAll = false
|
||||
loadAllPromise = null
|
||||
forecastRequestId += 1
|
||||
savingsRequestId += 1
|
||||
quotaRequestId += 1
|
||||
providerStatsRequestId += 1
|
||||
apiKeyLeaderboardRequestId += 1
|
||||
})
|
||||
const points = computed(() => costs.value?.data.timeseries || [])
|
||||
const hasRequests = computed(() => points.value.some(point => point.request_count > 0))
|
||||
const labels = computed(() => points.value.map(point => timestamp(point.bucket_start, range.value.timezone)))
|
||||
const requestChart = computed(() => ({ labels: labels.value, datasets: [{ label: t('请求数', 'Requests'), data: points.value.map(point => point.request_count), borderColor: '#ce6545', backgroundColor: '#ce654518', fill: true, pointRadius: 1, borderWidth: 2, tension: 0.2 }] }))
|
||||
const tokenChart = computed(() => ({ labels: labels.value, datasets: [{ label: t('输入 Token', 'Input tokens'), data: points.value.map(point => point.input_tokens), borderColor: '#0d9488', pointRadius: 1, borderWidth: 2, tension: 0.2 }, { label: t('输出 Token', 'Output tokens'), data: points.value.map(point => point.output_tokens), borderColor: '#d97706', pointRadius: 1, borderWidth: 2, tension: 0.2 }] }))
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user