Commit 6901b64f authored by cyhhao's avatar cyhhao
Browse files

merge: sync upstream changes

parents 32c47b15 dae0d532
/**
* Vitest 测试环境设置
* 提供全局 mock 和测试工具
*/
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
// Mock requestIdleCallback (Safari < 15 不支持)
if (typeof globalThis.requestIdleCallback === 'undefined') {
globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => {
return window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 1)
}) as unknown as typeof requestIdleCallback
}
if (typeof globalThis.cancelIdleCallback === 'undefined') {
globalThis.cancelIdleCallback = ((id: number) => {
window.clearTimeout(id)
}) as unknown as typeof cancelIdleCallback
}
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver
// Mock ResizeObserver
class MockResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
// Vue Test Utils 全局配置
config.global.stubs = {
// 可以在这里添加全局 stub
}
// 设置全局测试超时
vi.setConfig({ testTimeout: 10000 })
...@@ -46,6 +46,10 @@ export interface TrendParams { ...@@ -46,6 +46,10 @@ export interface TrendParams {
granularity?: 'day' | 'hour' granularity?: 'day' | 'hour'
user_id?: number user_id?: number
api_key_id?: number api_key_id?: number
model?: string
account_id?: number
group_id?: number
stream?: boolean
} }
export interface TrendResponse { export interface TrendResponse {
...@@ -70,6 +74,10 @@ export interface ModelStatsParams { ...@@ -70,6 +74,10 @@ export interface ModelStatsParams {
end_date?: string end_date?: string
user_id?: number user_id?: number
api_key_id?: number api_key_id?: number
model?: string
account_id?: number
group_id?: number
stream?: boolean
} }
export interface ModelStatsResponse { export interface ModelStatsResponse {
......
...@@ -17,6 +17,47 @@ export interface OpsRequestOptions { ...@@ -17,6 +17,47 @@ export interface OpsRequestOptions {
export interface OpsRetryRequest { export interface OpsRetryRequest {
mode: OpsRetryMode mode: OpsRetryMode
pinned_account_id?: number pinned_account_id?: number
force?: boolean
}
export interface OpsRetryAttempt {
id: number
created_at: string
requested_by_user_id: number
source_error_id: number
mode: string
pinned_account_id?: number | null
pinned_account_name?: string
status: string
started_at?: string | null
finished_at?: string | null
duration_ms?: number | null
success?: boolean | null
http_status_code?: number | null
upstream_request_id?: string | null
used_account_id?: number | null
used_account_name?: string
response_preview?: string | null
response_truncated?: boolean | null
result_request_id?: string | null
result_error_id?: number | null
error_message?: string | null
}
export type OpsUpstreamErrorEvent = {
at_unix_ms?: number
platform?: string
account_id?: number
account_name?: string
upstream_status_code?: number
upstream_request_id?: string
upstream_request_body?: string
kind?: string
message?: string
detail?: string
} }
export interface OpsRetryResult { export interface OpsRetryResult {
...@@ -252,6 +293,7 @@ export interface OpsJobHeartbeat { ...@@ -252,6 +293,7 @@ export interface OpsJobHeartbeat {
last_error_at?: string | null last_error_at?: string | null
last_error?: string | null last_error?: string | null
last_duration_ms?: number | null last_duration_ms?: number | null
last_result?: string | null
updated_at: string updated_at: string
} }
...@@ -626,8 +668,6 @@ export type MetricType = ...@@ -626,8 +668,6 @@ export type MetricType =
| 'success_rate' | 'success_rate'
| 'error_rate' | 'error_rate'
| 'upstream_error_rate' | 'upstream_error_rate'
| 'p95_latency_ms'
| 'p99_latency_ms'
| 'cpu_usage_percent' | 'cpu_usage_percent'
| 'memory_usage_percent' | 'memory_usage_percent'
| 'concurrency_queue_depth' | 'concurrency_queue_depth'
...@@ -663,7 +703,7 @@ export interface AlertEvent { ...@@ -663,7 +703,7 @@ export interface AlertEvent {
id: number id: number
rule_id: number rule_id: number
severity: OpsSeverity | string severity: OpsSeverity | string
status: 'firing' | 'resolved' | string status: 'firing' | 'resolved' | 'manual_resolved' | string
title?: string title?: string
description?: string description?: string
metric_value?: number metric_value?: number
...@@ -701,10 +741,9 @@ export interface EmailNotificationConfig { ...@@ -701,10 +741,9 @@ export interface EmailNotificationConfig {
} }
export interface OpsMetricThresholds { export interface OpsMetricThresholds {
sla_percent_min?: number | null // SLA低于此值变红 sla_percent_min?: number | null // SLA低于此值变红
latency_p99_ms_max?: number | null // 延迟P99高于此值变红 ttft_p99_ms_max?: number | null // TTFT P99高于此值变红
ttft_p99_ms_max?: number | null // TTFT P99高于此值变红 request_error_rate_percent_max?: number | null // 请求错误率高于此值变红
request_error_rate_percent_max?: number | null // 请求错误率高于此值变红
upstream_error_rate_percent_max?: number | null // 上游错误率高于此值变红 upstream_error_rate_percent_max?: number | null // 上游错误率高于此值变红
} }
...@@ -735,6 +774,8 @@ export interface OpsAdvancedSettings { ...@@ -735,6 +774,8 @@ export interface OpsAdvancedSettings {
data_retention: OpsDataRetentionSettings data_retention: OpsDataRetentionSettings
aggregation: OpsAggregationSettings aggregation: OpsAggregationSettings
ignore_count_tokens_errors: boolean ignore_count_tokens_errors: boolean
ignore_context_canceled: boolean
ignore_no_available_accounts: boolean
auto_refresh_enabled: boolean auto_refresh_enabled: boolean
auto_refresh_interval_seconds: number auto_refresh_interval_seconds: number
} }
...@@ -754,21 +795,37 @@ export interface OpsAggregationSettings { ...@@ -754,21 +795,37 @@ export interface OpsAggregationSettings {
export interface OpsErrorLog { export interface OpsErrorLog {
id: number id: number
created_at: string created_at: string
// Standardized classification
phase: OpsPhase phase: OpsPhase
type: string type: string
error_owner: 'client' | 'provider' | 'platform' | string
error_source: 'client_request' | 'upstream_http' | 'gateway' | string
severity: OpsSeverity severity: OpsSeverity
status_code: number status_code: number
platform: string platform: string
model: string model: string
latency_ms?: number | null
is_retryable: boolean
retry_count: number
resolved: boolean
resolved_at?: string | null
resolved_by_user_id?: number | null
resolved_retry_id?: number | null
client_request_id: string client_request_id: string
request_id: string request_id: string
message: string message: string
user_id?: number | null user_id?: number | null
user_email: string
api_key_id?: number | null api_key_id?: number | null
account_id?: number | null account_id?: number | null
account_name: string
group_id?: number | null group_id?: number | null
group_name: string
client_ip?: string | null client_ip?: string | null
request_path?: string request_path?: string
...@@ -890,7 +947,9 @@ export async function getErrorDistribution( ...@@ -890,7 +947,9 @@ export async function getErrorDistribution(
return data return data
} }
export async function listErrorLogs(params: { export type OpsErrorListView = 'errors' | 'excluded' | 'all'
export type OpsErrorListQueryParams = {
page?: number page?: number
page_size?: number page_size?: number
time_range?: string time_range?: string
...@@ -899,10 +958,20 @@ export async function listErrorLogs(params: { ...@@ -899,10 +958,20 @@ export async function listErrorLogs(params: {
platform?: string platform?: string
group_id?: number | null group_id?: number | null
account_id?: number | null account_id?: number | null
phase?: string phase?: string
error_owner?: string
error_source?: string
resolved?: string
view?: OpsErrorListView
q?: string q?: string
status_codes?: string status_codes?: string
}): Promise<OpsErrorLogsResponse> { status_codes_other?: string
}
// Legacy unified endpoints
export async function listErrorLogs(params: OpsErrorListQueryParams): Promise<OpsErrorLogsResponse> {
const { data } = await apiClient.get<OpsErrorLogsResponse>('/admin/ops/errors', { params }) const { data } = await apiClient.get<OpsErrorLogsResponse>('/admin/ops/errors', { params })
return data return data
} }
...@@ -917,6 +986,70 @@ export async function retryErrorRequest(id: number, req: OpsRetryRequest): Promi ...@@ -917,6 +986,70 @@ export async function retryErrorRequest(id: number, req: OpsRetryRequest): Promi
return data return data
} }
export async function listRetryAttempts(errorId: number, limit = 50): Promise<OpsRetryAttempt[]> {
const { data } = await apiClient.get<OpsRetryAttempt[]>(`/admin/ops/errors/${errorId}/retries`, { params: { limit } })
return data
}
export async function updateErrorResolved(errorId: number, resolved: boolean): Promise<void> {
await apiClient.put(`/admin/ops/errors/${errorId}/resolve`, { resolved })
}
// New split endpoints
export async function listRequestErrors(params: OpsErrorListQueryParams): Promise<OpsErrorLogsResponse> {
const { data } = await apiClient.get<OpsErrorLogsResponse>('/admin/ops/request-errors', { params })
return data
}
export async function listUpstreamErrors(params: OpsErrorListQueryParams): Promise<OpsErrorLogsResponse> {
const { data } = await apiClient.get<OpsErrorLogsResponse>('/admin/ops/upstream-errors', { params })
return data
}
export async function getRequestErrorDetail(id: number): Promise<OpsErrorDetail> {
const { data } = await apiClient.get<OpsErrorDetail>(`/admin/ops/request-errors/${id}`)
return data
}
export async function getUpstreamErrorDetail(id: number): Promise<OpsErrorDetail> {
const { data } = await apiClient.get<OpsErrorDetail>(`/admin/ops/upstream-errors/${id}`)
return data
}
export async function retryRequestErrorClient(id: number): Promise<OpsRetryResult> {
const { data } = await apiClient.post<OpsRetryResult>(`/admin/ops/request-errors/${id}/retry-client`, {})
return data
}
export async function retryRequestErrorUpstreamEvent(id: number, idx: number): Promise<OpsRetryResult> {
const { data } = await apiClient.post<OpsRetryResult>(`/admin/ops/request-errors/${id}/upstream-errors/${idx}/retry`, {})
return data
}
export async function retryUpstreamError(id: number): Promise<OpsRetryResult> {
const { data } = await apiClient.post<OpsRetryResult>(`/admin/ops/upstream-errors/${id}/retry`, {})
return data
}
export async function updateRequestErrorResolved(errorId: number, resolved: boolean): Promise<void> {
await apiClient.put(`/admin/ops/request-errors/${errorId}/resolve`, { resolved })
}
export async function updateUpstreamErrorResolved(errorId: number, resolved: boolean): Promise<void> {
await apiClient.put(`/admin/ops/upstream-errors/${errorId}/resolve`, { resolved })
}
export async function listRequestErrorUpstreamErrors(
id: number,
params: OpsErrorListQueryParams = {},
options: { include_detail?: boolean } = {}
): Promise<PaginatedResponse<OpsErrorDetail>> {
const query: Record<string, any> = { ...params }
if (options.include_detail) query.include_detail = '1'
const { data } = await apiClient.get<PaginatedResponse<OpsErrorDetail>>(`/admin/ops/request-errors/${id}/upstream-errors`, { params: query })
return data
}
export async function listRequestDetails(params: OpsRequestDetailsParams): Promise<OpsRequestDetailsResponse> { export async function listRequestDetails(params: OpsRequestDetailsParams): Promise<OpsRequestDetailsResponse> {
const { data } = await apiClient.get<OpsRequestDetailsResponse>('/admin/ops/requests', { params }) const { data } = await apiClient.get<OpsRequestDetailsResponse>('/admin/ops/requests', { params })
return data return data
...@@ -942,11 +1075,45 @@ export async function deleteAlertRule(id: number): Promise<void> { ...@@ -942,11 +1075,45 @@ export async function deleteAlertRule(id: number): Promise<void> {
await apiClient.delete(`/admin/ops/alert-rules/${id}`) await apiClient.delete(`/admin/ops/alert-rules/${id}`)
} }
export async function listAlertEvents(limit = 100): Promise<AlertEvent[]> { export interface AlertEventsQuery {
const { data } = await apiClient.get<AlertEvent[]>('/admin/ops/alert-events', { params: { limit } }) limit?: number
status?: string
severity?: string
email_sent?: boolean
time_range?: string
start_time?: string
end_time?: string
before_fired_at?: string
before_id?: number
platform?: string
group_id?: number
}
export async function listAlertEvents(params: AlertEventsQuery = {}): Promise<AlertEvent[]> {
const { data } = await apiClient.get<AlertEvent[]>('/admin/ops/alert-events', { params })
return data
}
export async function getAlertEvent(id: number): Promise<AlertEvent> {
const { data } = await apiClient.get<AlertEvent>(`/admin/ops/alert-events/${id}`)
return data return data
} }
export async function updateAlertEventStatus(id: number, status: 'resolved' | 'manual_resolved'): Promise<void> {
await apiClient.put(`/admin/ops/alert-events/${id}/status`, { status })
}
export async function createAlertSilence(payload: {
rule_id: number
platform: string
group_id?: number | null
region?: string | null
until: string
reason?: string
}): Promise<void> {
await apiClient.post('/admin/ops/alert-silences', payload)
}
// Email notification config // Email notification config
export async function getEmailNotificationConfig(): Promise<EmailNotificationConfig> { export async function getEmailNotificationConfig(): Promise<EmailNotificationConfig> {
const { data } = await apiClient.get<EmailNotificationConfig>('/admin/ops/email-notification/config') const { data } = await apiClient.get<EmailNotificationConfig>('/admin/ops/email-notification/config')
...@@ -1001,15 +1168,35 @@ export const opsAPI = { ...@@ -1001,15 +1168,35 @@ export const opsAPI = {
getAccountAvailabilityStats, getAccountAvailabilityStats,
getRealtimeTrafficSummary, getRealtimeTrafficSummary,
subscribeQPS, subscribeQPS,
// Legacy unified endpoints
listErrorLogs, listErrorLogs,
getErrorLogDetail, getErrorLogDetail,
retryErrorRequest, retryErrorRequest,
listRetryAttempts,
updateErrorResolved,
// New split endpoints
listRequestErrors,
listUpstreamErrors,
getRequestErrorDetail,
getUpstreamErrorDetail,
retryRequestErrorClient,
retryRequestErrorUpstreamEvent,
retryUpstreamError,
updateRequestErrorResolved,
updateUpstreamErrorResolved,
listRequestErrorUpstreamErrors,
listRequestDetails, listRequestDetails,
listAlertRules, listAlertRules,
createAlertRule, createAlertRule,
updateAlertRule, updateAlertRule,
deleteAlertRule, deleteAlertRule,
listAlertEvents, listAlertEvents,
getAlertEvent,
updateAlertEventStatus,
createAlertSilence,
getEmailNotificationConfig, getEmailNotificationConfig,
updateEmailNotificationConfig, updateEmailNotificationConfig,
getAlertRuntimeSettings, getAlertRuntimeSettings,
......
...@@ -4,7 +4,13 @@ ...@@ -4,7 +4,13 @@
*/ */
import { apiClient } from '../client' import { apiClient } from '../client'
import type { Proxy, CreateProxyRequest, UpdateProxyRequest, PaginatedResponse } from '@/types' import type {
Proxy,
ProxyAccountSummary,
CreateProxyRequest,
UpdateProxyRequest,
PaginatedResponse
} from '@/types'
/** /**
* List all proxies with pagination * List all proxies with pagination
...@@ -120,6 +126,7 @@ export async function testProxy(id: number): Promise<{ ...@@ -120,6 +126,7 @@ export async function testProxy(id: number): Promise<{
city?: string city?: string
region?: string region?: string
country?: string country?: string
country_code?: string
}> { }> {
const { data } = await apiClient.post<{ const { data } = await apiClient.post<{
success: boolean success: boolean
...@@ -129,6 +136,7 @@ export async function testProxy(id: number): Promise<{ ...@@ -129,6 +136,7 @@ export async function testProxy(id: number): Promise<{
city?: string city?: string
region?: string region?: string
country?: string country?: string
country_code?: string
}>(`/admin/proxies/${id}/test`) }>(`/admin/proxies/${id}/test`)
return data return data
} }
...@@ -160,8 +168,8 @@ export async function getStats(id: number): Promise<{ ...@@ -160,8 +168,8 @@ export async function getStats(id: number): Promise<{
* @param id - Proxy ID * @param id - Proxy ID
* @returns List of accounts using the proxy * @returns List of accounts using the proxy
*/ */
export async function getProxyAccounts(id: number): Promise<PaginatedResponse<any>> { export async function getProxyAccounts(id: number): Promise<ProxyAccountSummary[]> {
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/proxies/${id}/accounts`) const { data } = await apiClient.get<ProxyAccountSummary[]>(`/admin/proxies/${id}/accounts`)
return data return data
} }
...@@ -189,6 +197,17 @@ export async function batchCreate( ...@@ -189,6 +197,17 @@ export async function batchCreate(
return data return data
} }
export async function batchDelete(ids: number[]): Promise<{
deleted_ids: number[]
skipped: Array<{ id: number; reason: string }>
}> {
const { data } = await apiClient.post<{
deleted_ids: number[]
skipped: Array<{ id: number; reason: string }>
}>('/admin/proxies/batch-delete', { ids })
return data
}
export const proxiesAPI = { export const proxiesAPI = {
list, list,
getAll, getAll,
...@@ -201,7 +220,8 @@ export const proxiesAPI = { ...@@ -201,7 +220,8 @@ export const proxiesAPI = {
testProxy, testProxy,
getStats, getStats,
getProxyAccounts, getProxyAccounts,
batchCreate batchCreate,
batchDelete
} }
export default proxiesAPI export default proxiesAPI
...@@ -16,6 +16,7 @@ export interface AdminUsageStatsResponse { ...@@ -16,6 +16,7 @@ export interface AdminUsageStatsResponse {
total_tokens: number total_tokens: number
total_cost: number total_cost: number
total_actual_cost: number total_actual_cost: number
total_account_cost?: number
average_duration_ms: number average_duration_ms: number
} }
......
<template>
<div class="flex flex-col gap-1.5">
<!-- 并发槽位 -->
<div class="flex items-center gap-1.5">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
concurrencyClass
]"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
<span class="font-mono">{{ currentConcurrency }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ account.concurrency }}</span>
</span>
</div>
<!-- 5h窗口费用限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div v-if="showWindowCost" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
windowCostClass
]"
:title="windowCostTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-mono">${{ formatCost(currentWindowCost) }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">${{ formatCost(account.window_cost_limit) }}</span>
</span>
</div>
<!-- 会话数量限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div v-if="showSessionLimit" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
sessionLimitClass
]"
:title="sessionLimitTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span class="font-mono">{{ activeSessions }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ account.max_sessions }}</span>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
const props = defineProps<{
account: Account
}>()
const { t } = useI18n()
// 当前并发数
const currentConcurrency = computed(() => props.account.current_concurrency || 0)
// 是否为 Anthropic OAuth/SetupToken 账号
const isAnthropicOAuthOrSetupToken = computed(() => {
return (
props.account.platform === 'anthropic' &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
)
})
// 是否显示窗口费用限制
const showWindowCost = computed(() => {
return (
isAnthropicOAuthOrSetupToken.value &&
props.account.window_cost_limit !== undefined &&
props.account.window_cost_limit !== null &&
props.account.window_cost_limit > 0
)
})
// 当前窗口费用
const currentWindowCost = computed(() => props.account.current_window_cost ?? 0)
// 是否显示会话限制
const showSessionLimit = computed(() => {
return (
isAnthropicOAuthOrSetupToken.value &&
props.account.max_sessions !== undefined &&
props.account.max_sessions !== null &&
props.account.max_sessions > 0
)
})
// 当前活跃会话数
const activeSessions = computed(() => props.account.active_sessions ?? 0)
// 并发状态样式
const concurrencyClass = computed(() => {
const current = currentConcurrency.value
const max = props.account.concurrency
if (current >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current > 0) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
})
// 窗口费用状态样式
const windowCostClass = computed(() => {
if (!showWindowCost.value) return ''
const current = currentWindowCost.value
const limit = props.account.window_cost_limit || 0
const reserve = props.account.window_cost_sticky_reserve || 10
// >= 阈值+预留: 完全不可调度 (红色)
if (current >= limit + reserve) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
// >= 阈值: 仅粘性会话 (橙色)
if (current >= limit) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
// >= 80% 阈值: 警告 (黄色)
if (current >= limit * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
// 正常 (绿色)
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// 窗口费用提示文字
const windowCostTooltip = computed(() => {
if (!showWindowCost.value) return ''
const current = currentWindowCost.value
const limit = props.account.window_cost_limit || 0
const reserve = props.account.window_cost_sticky_reserve || 10
if (current >= limit + reserve) {
return t('admin.accounts.capacity.windowCost.blocked')
}
if (current >= limit) {
return t('admin.accounts.capacity.windowCost.stickyOnly')
}
return t('admin.accounts.capacity.windowCost.normal')
})
// 会话限制状态样式
const sessionLimitClass = computed(() => {
if (!showSessionLimit.value) return ''
const current = activeSessions.value
const max = props.account.max_sessions || 0
// >= 最大: 完全占满 (红色)
if (current >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
// >= 80%: 警告 (黄色)
if (current >= max * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
// 正常 (绿色)
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// 会话限制提示文字
const sessionLimitTooltip = computed(() => {
if (!showSessionLimit.value) return ''
const current = activeSessions.value
const max = props.account.max_sessions || 0
const idle = props.account.session_idle_timeout_minutes || 5
if (current >= max) {
return t('admin.accounts.capacity.sessions.full', { idle })
}
return t('admin.accounts.capacity.sessions.normal', { idle })
})
// 格式化费用显示
const formatCost = (value: number | null | undefined) => {
if (value === null || value === undefined) return '0'
return value.toFixed(2)
}
</script>
...@@ -73,11 +73,12 @@ ...@@ -73,11 +73,12 @@
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.accumulatedCost') }} {{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500" <span class="text-gray-400 dark:text-gray-500">
>({{ t('admin.accounts.stats.standardCost') }}: ${{ ({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
{{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost) formatCost(stats.summary.total_standard_cost)
}})</span }})
> </span>
</p> </p>
</div> </div>
...@@ -121,12 +122,15 @@ ...@@ -121,12 +122,15 @@
<p class="text-2xl font-bold text-gray-900 dark:text-white"> <p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.avg_daily_cost) }} ${{ formatCost(stats.summary.avg_daily_cost) }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ {{
t('admin.accounts.stats.basedOnActualDays', { t('admin.accounts.stats.basedOnActualDays', {
days: stats.summary.actual_days_used days: stats.summary.actual_days_used
}) })
}} }}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
</span>
</p> </p>
</div> </div>
...@@ -189,13 +193,17 @@ ...@@ -189,13 +193,17 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span >${{ formatCost(stats.summary.today?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests') t('admin.accounts.stats.requests')
...@@ -240,13 +248,17 @@ ...@@ -240,13 +248,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400" <span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span >${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests') t('admin.accounts.stats.requests')
...@@ -291,13 +303,17 @@ ...@@ -291,13 +303,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span >${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -397,13 +413,17 @@ ...@@ -397,13 +413,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.todayCost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span >${{ formatCost(stats.summary.today?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -517,14 +537,24 @@ const trendChartData = computed(() => { ...@@ -517,14 +537,24 @@ const trendChartData = computed(() => {
labels: stats.value.history.map((h) => h.label), labels: stats.value.history.map((h) => h.label),
datasets: [ datasets: [
{ {
label: t('admin.accounts.stats.cost') + ' (USD)', label: t('usage.accountBilled') + ' (USD)',
data: stats.value.history.map((h) => h.cost), data: stats.value.history.map((h) => h.actual_cost),
borderColor: '#3b82f6', borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)', backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true, fill: true,
tension: 0.3, tension: 0.3,
yAxisID: 'y' yAxisID: 'y'
}, },
{
label: t('usage.userBilled') + ' (USD)',
data: stats.value.history.map((h) => h.user_cost),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
fill: false,
tension: 0.3,
borderDash: [5, 5],
yAxisID: 'y'
},
{ {
label: t('admin.accounts.stats.requests'), label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests), data: stats.value.history.map((h) => h.requests),
...@@ -602,7 +632,7 @@ const lineChartOptions = computed(() => ({ ...@@ -602,7 +632,7 @@ const lineChartOptions = computed(() => ({
}, },
title: { title: {
display: true, display: true,
text: t('admin.accounts.stats.cost') + ' (USD)', text: t('usage.accountBilled') + ' (USD)',
color: '#3b82f6', color: '#3b82f6',
font: { font: {
size: 11 size: 11
......
...@@ -32,15 +32,20 @@ ...@@ -32,15 +32,20 @@
formatTokens(stats.tokens) formatTokens(stats.tokens)
}}</span> }}</span>
</div> </div>
<!-- Cost --> <!-- Cost (Account) -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400" <span class="text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}:</span>
>{{ t('admin.accounts.stats.cost') }}:</span
>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ <span class="font-medium text-emerald-600 dark:text-emerald-400">{{
formatCurrency(stats.cost) formatCurrency(stats.cost)
}}</span> }}</span>
</div> </div>
<!-- Cost (User/API Key) -->
<div v-if="stats.user_cost != null" class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatCurrency(stats.user_cost)
}}</span>
</div>
</div> </div>
<!-- No data --> <!-- No data -->
......
...@@ -459,7 +459,7 @@ ...@@ -459,7 +459,7 @@
</div> </div>
<!-- Concurrency & Priority --> <!-- Concurrency & Priority -->
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600 lg:grid-cols-3">
<div> <div>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label <label
...@@ -516,6 +516,36 @@ ...@@ -516,6 +516,36 @@
aria-labelledby="bulk-edit-priority-label" aria-labelledby="bulk-edit-priority-label"
/> />
</div> </div>
<div>
<div class="mb-3 flex items-center justify-between">
<label
id="bulk-edit-rate-multiplier-label"
class="input-label mb-0"
for="bulk-edit-rate-multiplier-enabled"
>
{{ t('admin.accounts.billingRateMultiplier') }}
</label>
<input
v-model="enableRateMultiplier"
id="bulk-edit-rate-multiplier-enabled"
type="checkbox"
aria-controls="bulk-edit-rate-multiplier"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<input
v-model.number="rateMultiplier"
id="bulk-edit-rate-multiplier"
type="number"
min="0"
step="0.01"
:disabled="!enableRateMultiplier"
class="input"
:class="!enableRateMultiplier && 'cursor-not-allowed opacity-50'"
aria-labelledby="bulk-edit-rate-multiplier-label"
/>
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div> </div>
<!-- Status --> <!-- Status -->
...@@ -655,6 +685,7 @@ const enableInterceptWarmup = ref(false) ...@@ -655,6 +685,7 @@ const enableInterceptWarmup = ref(false)
const enableProxy = ref(false) const enableProxy = ref(false)
const enableConcurrency = ref(false) const enableConcurrency = ref(false)
const enablePriority = ref(false) const enablePriority = ref(false)
const enableRateMultiplier = ref(false)
const enableStatus = ref(false) const enableStatus = ref(false)
const enableGroups = ref(false) const enableGroups = ref(false)
...@@ -670,6 +701,7 @@ const interceptWarmupRequests = ref(false) ...@@ -670,6 +701,7 @@ const interceptWarmupRequests = ref(false)
const proxyId = ref<number | null>(null) const proxyId = ref<number | null>(null)
const concurrency = ref(1) const concurrency = ref(1)
const priority = ref(1) const priority = ref(1)
const rateMultiplier = ref(1)
const status = ref<'active' | 'inactive'>('active') const status = ref<'active' | 'inactive'>('active')
const groupIds = ref<number[]>([]) const groupIds = ref<number[]>([])
...@@ -863,6 +895,10 @@ const buildUpdatePayload = (): Record<string, unknown> | null => { ...@@ -863,6 +895,10 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
updates.priority = priority.value updates.priority = priority.value
} }
if (enableRateMultiplier.value) {
updates.rate_multiplier = rateMultiplier.value
}
if (enableStatus.value) { if (enableStatus.value) {
updates.status = status.value updates.status = status.value
} }
...@@ -923,6 +959,7 @@ const handleSubmit = async () => { ...@@ -923,6 +959,7 @@ const handleSubmit = async () => {
enableProxy.value || enableProxy.value ||
enableConcurrency.value || enableConcurrency.value ||
enablePriority.value || enablePriority.value ||
enableRateMultiplier.value ||
enableStatus.value || enableStatus.value ||
enableGroups.value enableGroups.value
...@@ -977,6 +1014,7 @@ watch( ...@@ -977,6 +1014,7 @@ watch(
enableProxy.value = false enableProxy.value = false
enableConcurrency.value = false enableConcurrency.value = false
enablePriority.value = false enablePriority.value = false
enableRateMultiplier.value = false
enableStatus.value = false enableStatus.value = false
enableGroups.value = false enableGroups.value = false
...@@ -991,6 +1029,7 @@ watch( ...@@ -991,6 +1029,7 @@ watch(
proxyId.value = null proxyId.value = null
concurrency.value = 1 concurrency.value = 1
priority.value = 1 priority.value = 1
rateMultiplier.value = 1
status.value = 'active' status.value = 'active'
groupIds.value = [] groupIds.value = []
} }
......
...@@ -1196,7 +1196,7 @@ ...@@ -1196,7 +1196,7 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" /> <ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div> <div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label> <label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" min="1" class="input" /> <input v-model.number="form.concurrency" type="number" min="1" class="input" />
...@@ -1212,6 +1212,11 @@ ...@@ -1212,6 +1212,11 @@
/> />
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p> <p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div> </div>
<div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label> <label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
...@@ -1832,6 +1837,7 @@ const form = reactive({ ...@@ -1832,6 +1837,7 @@ const form = reactive({
proxy_id: null as number | null, proxy_id: null as number | null,
concurrency: 10, concurrency: 10,
priority: 1, priority: 1,
rate_multiplier: 1,
group_ids: [] as number[], group_ids: [] as number[],
expires_at: null as number | null expires_at: null as number | null
}) })
...@@ -2119,6 +2125,7 @@ const resetForm = () => { ...@@ -2119,6 +2125,7 @@ const resetForm = () => {
form.proxy_id = null form.proxy_id = null
form.concurrency = 10 form.concurrency = 10
form.priority = 1 form.priority = 1
form.rate_multiplier = 1
form.group_ids = [] form.group_ids = []
form.expires_at = null form.expires_at = null
accountCategory.value = 'oauth-based' accountCategory.value = 'oauth-based'
...@@ -2272,6 +2279,7 @@ const createAccountAndFinish = async ( ...@@ -2272,6 +2279,7 @@ const createAccountAndFinish = async (
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids, group_ids: form.group_ids,
expires_at: form.expires_at, expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value auto_pause_on_expired: autoPauseOnExpired.value
...@@ -2490,6 +2498,7 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -2490,6 +2498,7 @@ const handleCookieAuth = async (sessionKey: string) => {
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids, group_ids: form.group_ids,
expires_at: form.expires_at, expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value auto_pause_on_expired: autoPauseOnExpired.value
......
...@@ -549,7 +549,7 @@ ...@@ -549,7 +549,7 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" /> <ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div> <div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label> <label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" min="1" class="input" /> <input v-model.number="form.concurrency" type="number" min="1" class="input" />
...@@ -564,6 +564,11 @@ ...@@ -564,6 +564,11 @@
data-tour="account-form-priority" data-tour="account-form-priority"
/> />
</div> </div>
<div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label> <label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
...@@ -599,6 +604,136 @@ ...@@ -599,6 +604,136 @@
</div> </div>
</div> </div>
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) -->
<div
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.hint') }}
</p>
</div>
<!-- Window Cost Limit -->
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.windowCost.label') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.windowCost.hint') }}
</p>
</div>
<button
type="button"
@click="windowCostEnabled = !windowCostEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="windowCostEnabled" class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.limit') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
<input
v-model.number="windowCostLimit"
type="number"
min="0"
step="1"
class="input pl-7"
:placeholder="t('admin.accounts.quotaControl.windowCost.limitPlaceholder')"
/>
</div>
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.limitHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.stickyReserve') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
<input
v-model.number="windowCostStickyReserve"
type="number"
min="0"
step="1"
class="input pl-7"
:placeholder="t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')"
/>
</div>
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.stickyReserveHint') }}</p>
</div>
</div>
</div>
<!-- Session Limit -->
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionLimit.label') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.sessionLimit.hint') }}
</p>
</div>
<button
type="button"
@click="sessionLimitEnabled = !sessionLimitEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="sessionLimitEnabled" class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessions') }}</label>
<input
v-model.number="maxSessions"
type="number"
min="1"
step="1"
class="input"
:placeholder="t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessionsHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeout') }}</label>
<div class="relative">
<input
v-model.number="sessionIdleTimeout"
type="number"
min="1"
step="1"
class="input pr-12"
:placeholder="t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">{{ t('common.minutes') }}</span>
</div>
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeoutHint') }}</p>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div> <div>
<label class="input-label">{{ t('common.status') }}</label> <label class="input-label">{{ t('common.status') }}</label>
...@@ -762,6 +897,14 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch ...@@ -762,6 +897,14 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
// Quota control state (Anthropic OAuth/SetupToken only)
const windowCostEnabled = ref(false)
const windowCostLimit = ref<number | null>(null)
const windowCostStickyReserve = ref<number | null>(null)
const sessionLimitEnabled = ref(false)
const maxSessions = ref<number | null>(null)
const sessionIdleTimeout = ref<number | null>(null)
// Computed: current preset mappings based on platform // Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic')) const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
const tempUnschedPresets = computed(() => [ const tempUnschedPresets = computed(() => [
...@@ -807,6 +950,7 @@ const form = reactive({ ...@@ -807,6 +950,7 @@ const form = reactive({
proxy_id: null as number | null, proxy_id: null as number | null,
concurrency: 1, concurrency: 1,
priority: 1, priority: 1,
rate_multiplier: 1,
status: 'active' as 'active' | 'inactive', status: 'active' as 'active' | 'inactive',
group_ids: [] as number[], group_ids: [] as number[],
expires_at: null as number | null expires_at: null as number | null
...@@ -834,6 +978,7 @@ watch( ...@@ -834,6 +978,7 @@ watch(
form.proxy_id = newAccount.proxy_id form.proxy_id = newAccount.proxy_id
form.concurrency = newAccount.concurrency form.concurrency = newAccount.concurrency
form.priority = newAccount.priority form.priority = newAccount.priority
form.rate_multiplier = newAccount.rate_multiplier ?? 1
form.status = newAccount.status as 'active' | 'inactive' form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || [] form.group_ids = newAccount.group_ids || []
form.expires_at = newAccount.expires_at ?? null form.expires_at = newAccount.expires_at ?? null
...@@ -847,6 +992,9 @@ watch( ...@@ -847,6 +992,9 @@ watch(
const extra = newAccount.extra as Record<string, unknown> | undefined const extra = newAccount.extra as Record<string, unknown> | undefined
mixedScheduling.value = extra?.mixed_scheduling === true mixedScheduling.value = extra?.mixed_scheduling === true
// Load quota control settings (Anthropic OAuth/SetupToken only)
loadQuotaControlSettings(newAccount)
loadTempUnschedRules(credentials) loadTempUnschedRules(credentials)
// Initialize API Key fields for apikey type // Initialize API Key fields for apikey type
...@@ -1080,6 +1228,35 @@ function loadTempUnschedRules(credentials?: Record<string, unknown>) { ...@@ -1080,6 +1228,35 @@ function loadTempUnschedRules(credentials?: Record<string, unknown>) {
}) })
} }
// Load quota control settings from account (Anthropic OAuth/SetupToken only)
function loadQuotaControlSettings(account: Account) {
// Reset all quota control state first
windowCostEnabled.value = false
windowCostLimit.value = null
windowCostStickyReserve.value = null
sessionLimitEnabled.value = false
maxSessions.value = null
sessionIdleTimeout.value = null
// Only applies to Anthropic OAuth/SetupToken accounts
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
return
}
// Load from extra field (via backend DTO fields)
if (account.window_cost_limit != null && account.window_cost_limit > 0) {
windowCostEnabled.value = true
windowCostLimit.value = account.window_cost_limit
windowCostStickyReserve.value = account.window_cost_sticky_reserve ?? 10
}
if (account.max_sessions != null && account.max_sessions > 0) {
sessionLimitEnabled.value = true
maxSessions.value = account.max_sessions
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5
}
}
function formatTempUnschedKeywords(value: unknown) { function formatTempUnschedKeywords(value: unknown) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
...@@ -1207,6 +1384,32 @@ const handleSubmit = async () => { ...@@ -1207,6 +1384,32 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra
if (props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
// Window cost limit settings
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
newExtra.window_cost_limit = windowCostLimit.value
newExtra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
} else {
delete newExtra.window_cost_limit
delete newExtra.window_cost_sticky_reserve
}
// Session limit settings
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
newExtra.max_sessions = maxSessions.value
newExtra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
} else {
delete newExtra.max_sessions
delete newExtra.session_idle_timeout_minutes
}
updatePayload.extra = newExtra
}
await adminAPI.accounts.update(props.account.id, updatePayload) await adminAPI.accounts.update(props.account.id, updatePayload)
appStore.showSuccess(t('admin.accounts.accountUpdated')) appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated') emit('updated')
......
...@@ -15,7 +15,13 @@ ...@@ -15,7 +15,13 @@
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }} {{ formatTokens }}
</span> </span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> A ${{ formatAccountCost }} </span>
<span
v-if="windowStats?.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
U ${{ formatUserCost }}
</span>
</div> </div>
</div> </div>
...@@ -149,8 +155,13 @@ const formatTokens = computed(() => { ...@@ -149,8 +155,13 @@ const formatTokens = computed(() => {
return t.toString() return t.toString()
}) })
const formatCost = computed(() => { const formatAccountCost = computed(() => {
if (!props.windowStats) return '0.00' if (!props.windowStats) return '0.00'
return props.windowStats.cost.toFixed(2) return props.windowStats.cost.toFixed(2)
}) })
const formatUserCost = computed(() => {
if (!props.windowStats || props.windowStats.user_cost == null) return '0.00'
return props.windowStats.user_cost.toFixed(2)
})
</script> </script>
...@@ -61,11 +61,12 @@ ...@@ -61,11 +61,12 @@
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.accumulatedCost') }} {{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500" <span class="text-gray-400 dark:text-gray-500">
>({{ t('admin.accounts.stats.standardCost') }}: ${{ ({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
{{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost) formatCost(stats.summary.total_standard_cost)
}})</span }})
> </span>
</p> </p>
</div> </div>
...@@ -108,12 +109,15 @@ ...@@ -108,12 +109,15 @@
<p class="text-2xl font-bold text-gray-900 dark:text-white"> <p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.avg_daily_cost) }} ${{ formatCost(stats.summary.avg_daily_cost) }}
</p> </p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ {{
t('admin.accounts.stats.basedOnActualDays', { t('admin.accounts.stats.basedOnActualDays', {
days: stats.summary.actual_days_used days: stats.summary.actual_days_used
}) })
}} }}
<span class="text-gray-400 dark:text-gray-500">
({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.avg_daily_user_cost) }})
</span>
</p> </p>
</div> </div>
...@@ -164,13 +168,17 @@ ...@@ -164,13 +168,17 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span >${{ formatCost(stats.summary.today?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests') t('admin.accounts.stats.requests')
...@@ -210,13 +218,17 @@ ...@@ -210,13 +218,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400" <span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span >${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_cost_day?.user_cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests') t('admin.accounts.stats.requests')
...@@ -260,13 +272,17 @@ ...@@ -260,13 +272,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span >${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
> >
</div> </div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.user_cost || 0) }}</span
>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -485,14 +501,24 @@ const trendChartData = computed(() => { ...@@ -485,14 +501,24 @@ const trendChartData = computed(() => {
labels: stats.value.history.map((h) => h.label), labels: stats.value.history.map((h) => h.label),
datasets: [ datasets: [
{ {
label: t('admin.accounts.stats.cost') + ' (USD)', label: t('usage.accountBilled') + ' (USD)',
data: stats.value.history.map((h) => h.cost), data: stats.value.history.map((h) => h.actual_cost),
borderColor: '#3b82f6', borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)', backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true, fill: true,
tension: 0.3, tension: 0.3,
yAxisID: 'y' yAxisID: 'y'
}, },
{
label: t('usage.userBilled') + ' (USD)',
data: stats.value.history.map((h) => h.user_cost),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
fill: false,
tension: 0.3,
borderDash: [5, 5],
yAxisID: 'y'
},
{ {
label: t('admin.accounts.stats.requests'), label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests), data: stats.value.history.map((h) => h.requests),
...@@ -570,7 +596,7 @@ const lineChartOptions = computed(() => ({ ...@@ -570,7 +596,7 @@ const lineChartOptions = computed(() => ({
}, },
title: { title: {
display: true, display: true,
text: t('admin.accounts.stats.cost') + ' (USD)', text: t('usage.accountBilled') + ' (USD)',
color: '#3b82f6', color: '#3b82f6',
font: { font: {
size: 11 size: 11
......
...@@ -27,9 +27,18 @@ ...@@ -27,9 +27,18 @@
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p> <p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p> <p class="text-xl font-bold text-green-600">
<p class="text-xs text-gray-400"> ${{ ((stats?.total_account_cost ?? stats?.total_actual_cost) || 0).toFixed(4) }}
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span> </p>
<p class="text-xs text-gray-400" v-if="stats?.total_account_cost != null">
{{ t('usage.userBilled') }}:
<span class="text-gray-300">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</span>
· {{ t('usage.standardCost') }}:
<span class="text-gray-300">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
<p class="text-xs text-gray-400" v-else>
{{ t('usage.standardCost') }}:
<span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p> </p>
</div> </div>
</div> </div>
......
...@@ -81,18 +81,23 @@ ...@@ -81,18 +81,23 @@
</template> </template>
<template #cell-cost="{ row }"> <template #cell-cost="{ row }">
<div class="flex items-center gap-1.5 text-sm"> <div class="text-sm">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span> <div class="flex items-center gap-1.5">
<!-- Cost Detail Tooltip --> <span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<div <!-- Cost Detail Tooltip -->
class="group relative" <div
@mouseenter="showTooltip($event, row)" class="group relative"
@mouseleave="hideTooltip" @mouseenter="showTooltip($event, row)"
> @mouseleave="hideTooltip"
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"> >
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" /> <div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
</div>
</div> </div>
</div> </div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
</div>
</div> </div>
</template> </template>
...@@ -202,14 +207,24 @@ ...@@ -202,14 +207,24 @@
<span class="text-gray-400">{{ t('usage.rate') }}</span> <span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span> <span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
</div> </div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span> <span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span> <span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.billed') }}</span> <span class="text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span> <span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
</span>
</div>
</div> </div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div> <div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div> </div>
......
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
<label class="input-label">{{ t('admin.users.username') }}</label> <label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" /> <input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label> <label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input v-model.number="form.balance" type="number" step="any" class="input" /> <input v-model.number="form.balance" type="number" step="any" class="input" />
......
<template> <template>
<div class="md:hidden space-y-3">
<template v-if="loading">
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
<div class="space-y-3">
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between">
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
<div class="h-8 w-full animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
</div>
</div>
</template>
<template v-else-if="!data || data.length === 0">
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dark-700 dark:bg-dark-900">
<slot name="empty">
<div class="flex flex-col items-center">
<Icon
name="inbox"
size="xl"
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
/>
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ t('empty.noData') }}
</p>
</div>
</slot>
</div>
</template>
<template v-else>
<div
v-for="(row, index) in sortedData"
:key="resolveRowKey(row, index)"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div class="space-y-3">
<div
v-for="column in columns.filter(c => c.key !== 'actions')"
:key="column.key"
class="flex items-start justify-between gap-4"
>
<span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
{{ column.label }}
</span>
<div class="text-right text-sm text-gray-900 dark:text-gray-100">
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot>
</div>
</div>
<div v-if="hasActionsColumn" class="border-t border-gray-200 pt-3 dark:border-dark-700">
<slot name="cell-actions" :row="row" :value="row['actions']" :expanded="actionsExpanded"></slot>
</div>
</div>
</div>
</template>
</div>
<div <div
ref="tableWrapperRef" ref="tableWrapperRef"
class="table-wrapper" class="table-wrapper hidden md:block"
:class="{ :class="{
'actions-expanded': actionsExpanded, 'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable 'is-scrollable': isScrollable
...@@ -22,29 +83,36 @@ ...@@ -22,29 +83,36 @@
]" ]"
@click="column.sortable && handleSort(column.key)" @click="column.sortable && handleSort(column.key)"
> >
<div class="flex items-center space-x-1"> <slot
<span>{{ column.label }}</span> :name="`header-${column.key}`"
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500"> :column="column"
<svg :sort-key="sortKey"
v-if="sortKey === column.key" :sort-order="sortOrder"
class="h-4 w-4" >
:class="{ 'rotate-180 transform': sortOrder === 'desc' }" <div class="flex items-center space-x-1">
fill="currentColor" <span>{{ column.label }}</span>
viewBox="0 0 20 20" <span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
> <svg
<path v-if="sortKey === column.key"
fill-rule="evenodd" class="h-4 w-4"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" :class="{ 'rotate-180 transform': sortOrder === 'desc' }"
clip-rule="evenodd" fill="currentColor"
/> viewBox="0 0 20 20"
</svg> >
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> <path
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
/> clip-rule="evenodd"
</svg> />
</span> </svg>
</div> <svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
</span>
</div>
</slot>
</th> </th>
</tr> </tr>
</thead> </thead>
...@@ -277,7 +345,10 @@ const sortedData = computed(() => { ...@@ -277,7 +345,10 @@ const sortedData = computed(() => {
}) })
}) })
// 检查第一列是否为勾选列 const hasActionsColumn = computed(() => {
return props.columns.some(column => column.key === 'actions')
})
const hasSelectColumn = computed(() => { const hasSelectColumn = computed(() => {
return props.columns.length > 0 && props.columns[0].key === 'select' return props.columns.length > 0 && props.columns[0].key === 'select'
}) })
......
<script setup lang="ts">
/**
* 导航进度条组件
* 在页面顶部显示加载进度,提供导航反馈
*/
import { computed } from 'vue'
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
const { isLoading } = useNavigationLoadingState()
// 进度条可见性
const isVisible = computed(() => isLoading.value)
</script>
<template>
<Transition name="progress-fade">
<div
v-show="isVisible"
class="navigation-progress"
role="progressbar"
aria-label="Loading"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100"
>
<div class="navigation-progress-bar" />
</div>
</Transition>
</template>
<style scoped>
.navigation-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 9999;
overflow: hidden;
background: transparent;
}
.navigation-progress-bar {
height: 100%;
width: 100%;
background: linear-gradient(
90deg,
transparent 0%,
theme('colors.primary.400') 20%,
theme('colors.primary.500') 50%,
theme('colors.primary.400') 80%,
transparent 100%
);
animation: progress-slide 1.5s ease-in-out infinite;
}
/* 暗色模式下的进度条颜色 */
:root.dark .navigation-progress-bar {
background: linear-gradient(
90deg,
transparent 0%,
theme('colors.primary.500') 20%,
theme('colors.primary.400') 50%,
theme('colors.primary.500') 80%,
transparent 100%
);
}
/* 进度条滑动动画 */
@keyframes progress-slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* 淡入淡出过渡 */
.progress-fade-enter-active {
transition: opacity 0.15s ease-out;
}
.progress-fade-leave-active {
transition: opacity 0.3s ease-out;
}
.progress-fade-enter-from,
.progress-fade-leave-to {
opacity: 0;
}
/* 减少动画模式 */
@media (prefers-reduced-motion: reduce) {
.navigation-progress-bar {
animation: progress-pulse 2s ease-in-out infinite;
}
@keyframes progress-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
}
</style>
/**
* NavigationProgress 组件单元测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import NavigationProgress from '../../common/NavigationProgress.vue'
// Mock useNavigationLoadingState
const mockIsLoading = ref(false)
vi.mock('@/composables/useNavigationLoading', () => ({
useNavigationLoadingState: () => ({
isLoading: mockIsLoading
})
}))
describe('NavigationProgress', () => {
beforeEach(() => {
mockIsLoading.value = false
})
it('isLoading=false 时进度条应该隐藏', () => {
mockIsLoading.value = false
const wrapper = mount(NavigationProgress)
const progressBar = wrapper.find('.navigation-progress')
// v-show 会设置 display: none
expect(progressBar.isVisible()).toBe(false)
})
it('isLoading=true 时进度条应该可见', async () => {
mockIsLoading.value = true
const wrapper = mount(NavigationProgress)
await wrapper.vm.$nextTick()
const progressBar = wrapper.find('.navigation-progress')
expect(progressBar.exists()).toBe(true)
expect(progressBar.isVisible()).toBe(true)
})
it('应该有正确的 ARIA 属性', () => {
mockIsLoading.value = true
const wrapper = mount(NavigationProgress)
const progressBar = wrapper.find('.navigation-progress')
expect(progressBar.attributes('role')).toBe('progressbar')
expect(progressBar.attributes('aria-label')).toBe('Loading')
expect(progressBar.attributes('aria-valuemin')).toBe('0')
expect(progressBar.attributes('aria-valuemax')).toBe('100')
})
it('进度条应该有动画 class', () => {
mockIsLoading.value = true
const wrapper = mount(NavigationProgress)
const bar = wrapper.find('.navigation-progress-bar')
expect(bar.exists()).toBe(true)
})
it('应该正确响应 isLoading 状态变化', async () => {
// 测试初始状态为 false
mockIsLoading.value = false
const wrapper = mount(NavigationProgress)
await wrapper.vm.$nextTick()
// 初始状态隐藏
expect(wrapper.find('.navigation-progress').isVisible()).toBe(false)
// 卸载后重新挂载以测试 true 状态
wrapper.unmount()
// 改变为 true 后重新挂载
mockIsLoading.value = true
const wrapper2 = mount(NavigationProgress)
await wrapper2.vm.$nextTick()
expect(wrapper2.find('.navigation-progress').isVisible()).toBe(true)
// 清理
wrapper2.unmount()
})
})
/**
* useNavigationLoading 组合式函数单元测试
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
useNavigationLoading,
_resetNavigationLoadingInstance
} from '../useNavigationLoading'
describe('useNavigationLoading', () => {
beforeEach(() => {
vi.useFakeTimers()
_resetNavigationLoadingInstance()
})
afterEach(() => {
vi.useRealTimers()
})
describe('startNavigation', () => {
it('导航开始时 isNavigating 应变为 true', () => {
const { isNavigating, startNavigation } = useNavigationLoading()
expect(isNavigating.value).toBe(false)
startNavigation()
expect(isNavigating.value).toBe(true)
})
it('导航开始后延迟显示加载指示器(防闪烁)', () => {
const { isLoading, startNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
// 立即检查,不应该显示
expect(isLoading.value).toBe(false)
// 经过防闪烁延迟后应该显示
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(true)
})
})
describe('endNavigation', () => {
it('导航结束时 isLoading 应变为 false', () => {
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(true)
endNavigation()
expect(isLoading.value).toBe(false)
})
it('导航结束时 isNavigating 应变为 false', () => {
const { isNavigating, startNavigation, endNavigation } = useNavigationLoading()
startNavigation()
expect(isNavigating.value).toBe(true)
endNavigation()
expect(isNavigating.value).toBe(false)
})
})
describe('快速导航(< 100ms)防闪烁', () => {
it('快速导航不应触发显示加载指示器', () => {
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
// 在防闪烁延迟之前结束导航
vi.advanceTimersByTime(ANTI_FLICKER_DELAY - 50)
endNavigation()
// 不应该显示加载指示器
expect(isLoading.value).toBe(false)
// 即使继续等待也不应该显示
vi.advanceTimersByTime(100)
expect(isLoading.value).toBe(false)
})
})
describe('cancelNavigation', () => {
it('导航取消时应正确重置状态', () => {
const { isLoading, startNavigation, cancelNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
vi.advanceTimersByTime(ANTI_FLICKER_DELAY / 2)
cancelNavigation()
// 取消后不应该触发显示
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(false)
})
})
describe('getNavigationDuration', () => {
it('应该返回正确的导航持续时间', () => {
const { startNavigation, getNavigationDuration } = useNavigationLoading()
expect(getNavigationDuration()).toBeNull()
startNavigation()
vi.advanceTimersByTime(500)
const duration = getNavigationDuration()
expect(duration).toBe(500)
})
it('导航结束后应返回 null', () => {
const { startNavigation, endNavigation, getNavigationDuration } = useNavigationLoading()
startNavigation()
vi.advanceTimersByTime(500)
endNavigation()
expect(getNavigationDuration()).toBeNull()
})
})
describe('resetState', () => {
it('应该重置所有状态', () => {
const { isLoading, isNavigating, startNavigation, resetState, ANTI_FLICKER_DELAY } = useNavigationLoading()
startNavigation()
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(true)
expect(isNavigating.value).toBe(true)
resetState()
expect(isLoading.value).toBe(false)
expect(isNavigating.value).toBe(false)
})
})
describe('连续导航场景', () => {
it('连续快速导航应正确处理状态', () => {
const { isLoading, startNavigation, cancelNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
// 第一次导航
startNavigation()
vi.advanceTimersByTime(30)
// 第二次导航(取消第一次)
cancelNavigation()
startNavigation()
vi.advanceTimersByTime(30)
// 第三次导航(取消第二次)
cancelNavigation()
startNavigation()
// 这次等待足够长时间
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
expect(isLoading.value).toBe(true)
// 结束导航
endNavigation()
expect(isLoading.value).toBe(false)
})
})
describe('ANTI_FLICKER_DELAY 常量', () => {
it('应该为 100ms', () => {
const { ANTI_FLICKER_DELAY } = useNavigationLoading()
expect(ANTI_FLICKER_DELAY).toBe(100)
})
})
})
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment