refactor(frontend): clarify module boundaries and simplify state

Move shared components and helpers to their actual owners, standardize
component directory names, and use concise names for common utilities.
Remove redundant wrappers, copied API models, and duplicate state.

Separate API key editing from key usage, isolate settings drafts, reuse
paged queries, and tie debounced queries and requests to their scopes.

Validated with frontend lint, type checking and production build, local
import checks, and 15 temporary behavioral regression groups.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-05 10:34:10 +08:00
co-authored by Codex
parent 28a57cbde5
commit 9c5fbdba70
167 changed files with 2196 additions and 2449 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { stableVisualIndex } from '../utils/visualTone'
import { stableVisualIndex } from '@/utils/color'
const props = withDefaults(
defineProps<{
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { AccountRequestBar } from './presenter'
import type { AccountRequestBar } from '@/components/account/account-usage-window/presenter'
withDefaults(
defineProps<{
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../../constants'
import type { AccountUsageWindowVariant } from './presenter'
import type { AccountQuotaWindow } from '@/api/index'
import type { AccountUsageWindowVariant } from '@/components/account/account-usage-window/presenter'
import { computed } from 'vue'
import AccountRequestTimeline from './AccountRequestTimeline.vue'
import { resolveAccountUsageWindowPresentation } from './presenter'
import AccountRequestTimeline from '@/components/account/account-usage-window/AccountRequestTimeline.vue'
import { resolveAccountUsageWindowPresentation } from '@/components/account/account-usage-window/presenter'
const props = withDefaults(
defineProps<{
@@ -1,4 +1,4 @@
import type { AccountQuotaWindow } from '../../constants'
import type { AccountQuotaWindow } from '@/api/index'
import { clamp } from 'es-toolkit'
import { isRecord } from '@/utils/data'
import { formatInteger } from '@/utils/format'
@@ -43,7 +43,7 @@ interface AccountUsageWindowVariantDefinition {
type QuotaWindowTone = 'danger' | 'success' | 'unknown' | 'warning'
export interface AccountUsageWindowPresentationInput {
interface AccountUsageWindowPresentationInput {
window: AccountQuotaWindow | undefined
variant: AccountUsageWindowVariant
showLocalValue: boolean
+6 -8
View File
@@ -18,9 +18,8 @@ const props = withDefaults(
)
const chartElement = useTemplateRef<HTMLElement>('chart')
const chartOption = shallowRef<EChartsOption>(props.option)
const chart = shallowRef<EChartsType>()
const pendingInitElement = shallowRef<HTMLElement>()
let pendingInitElement: HTMLElement | undefined
const style = computed(() => ({
height: `${props.height}px`,
@@ -34,13 +33,13 @@ function ensureChart(element: HTMLElement) {
if (chart.value || !elementHasSize(element))
return
chart.value = init(element, undefined, { renderer: 'canvas' })
applyOption(chartOption.value)
applyOption(props.option)
}
const { pause: pausePendingInit, resume: resumePendingInit } = useRafFn(
() => {
const element = pendingInitElement.value
pendingInitElement.value = undefined
const element = pendingInitElement
pendingInitElement = undefined
if (element) {
ensureChart(element)
}
@@ -49,12 +48,12 @@ const { pause: pausePendingInit, resume: resumePendingInit } = useRafFn(
)
function cancelPendingInit() {
pendingInitElement.value = undefined
pendingInitElement = undefined
pausePendingInit()
}
function scheduleInit(element: HTMLElement) {
pendingInitElement.value = element
pendingInitElement = element
resumePendingInit()
}
@@ -77,7 +76,6 @@ function dispose() {
watch(
() => props.option,
(option) => {
chartOption.value = option
if (chart.value) {
applyOption(option)
return
@@ -3,7 +3,7 @@ import type { InputField } from './schema'
import { BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover, BaseSelect, BaseTextarea } from '@codex-proxy/ui'
import { Braces, Info, List } from '@lucide/vue'
import { computed, nextTick, shallowRef, useTemplateRef } from 'vue'
import { jsonObjectError, parseJsonObject } from '@/utils/data'
import { parseJsonObject } from '@/utils/data'
import { inputFields } from './schema'
const props = defineProps<{
@@ -15,8 +15,9 @@ const props = defineProps<{
const text = defineModel<string>({ required: true })
const jsonMode = shallowRef(false)
const root = useTemplateRef<HTMLElement>('root')
const error = computed(() => jsonObjectError(text.value, props.maximumBytes))
const value = computed(() => error.value ? null : parseJsonObject(text.value, props.maximumBytes))
const parsed = computed(() => parseJsonObject(text.value, props.maximumBytes))
const error = computed(() => parsed.value.error)
const value = computed(() => parsed.value.value)
const fields = computed(() => inputFields(props.schema))
const hasExtra = computed(() => Object.keys(value.value ?? {}).some(key => !fields.value?.some(field => field.key === key)))
const incompatibleValue = computed(() => fields.value?.some((field) => {
@@ -1,11 +1,11 @@
<script setup lang="ts">
import type { HealthTimelinePoint } from '../constants'
import type { DashboardHealthTimelinePoint } from '@/api'
import { computed } from 'vue'
import { formatHealthCount, healthReliabilityValueClass, healthStatusMeta } from '../constants'
import { formatHealthCount, healthReliabilityValueClass, healthStatusMeta } from '@/components/usage/shared/health'
const props = defineProps<{
point: HealthTimelinePoint
point: DashboardHealthTimelinePoint
}>()
const status = computed(() => healthStatusMeta[props.point.status])
@@ -1,15 +1,15 @@
<script setup lang="ts">
import type { HealthTimeline, HealthTimelinePoint } from '../constants'
import type { DashboardHealthTimeline, DashboardHealthTimelinePoint } from '@/api'
import { BaseCard, BasePopover } from '@codex-proxy/ui'
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { formatHealthCount, healthLegend, healthReliabilityValueClass, healthStatusMeta } from '../constants'
import HealthTimelinePointPopover from './HealthTimelinePointPopover.vue'
import HealthTimelinePointPopover from '@/components/usage/HealthTimelinePointPopover.vue'
import { formatHealthCount, healthLegend, healthReliabilityValueClass, healthStatusMeta } from '@/components/usage/shared/health'
const props = defineProps<{
timeline: HealthTimeline
timeline: DashboardHealthTimeline
}>()
const healthPopoverArrowSurfaceClasses = {
@@ -21,25 +21,25 @@ const timelineGrid = useTemplateRef<HTMLElement>('timelineGrid')
const preferredMotion = usePreferredReducedMotion()
const points = computed(() => props.timeline.points)
const activePoint = shallowRef<HealthTimelinePoint>()
const activePoint = shallowRef<DashboardHealthTimelinePoint>()
const activeAnchor = shallowRef<HTMLElement | null>(null)
const popoverOpen = shallowRef(false)
const highlightedPointBucket = shallowRef<string>()
let wavedCellIndexes = new Set<number>()
function observedRequests(point: HealthTimelinePoint) {
function observedRequests(point: DashboardHealthTimelinePoint) {
return point.successRequests + point.failedRequests + point.cancelledRequests + point.callerErrorRequests
}
function isInteractivePoint(point: HealthTimelinePoint) {
function isInteractivePoint(point: DashboardHealthTimelinePoint) {
return point.status !== 'future' && observedRequests(point) > 0
}
function isActivePoint(point: HealthTimelinePoint) {
function isActivePoint(point: DashboardHealthTimelinePoint) {
return popoverOpen.value && highlightedPointBucket.value === point.bucketStart
}
function activatePoint(point: HealthTimelinePoint, pointIndex: number, event: Event) {
function activatePoint(point: DashboardHealthTimelinePoint, pointIndex: number, event: Event) {
if (!(event.currentTarget instanceof HTMLElement))
return
if (!isInteractivePoint(point)) {
@@ -162,7 +162,7 @@ function releasePointWave() {
wavedCellIndexes = new Set<number>()
}
function pointAccessibilityLabel(point: HealthTimelinePoint) {
function pointAccessibilityLabel(point: DashboardHealthTimelinePoint) {
const eligibleRequests = point.successRequests + point.failedRequests
return `${point.time},${healthStatusMeta[point.status].label},有效请求 ${formatHealthCount(eligibleRequests)},可用性 ${point.reliabilityDisplay}`
}
@@ -1,16 +1,16 @@
<script setup lang="ts">
import type { UsageDisplayRecord } from '../utils/records'
import type { UsageListRecord } from '@/api'
import { Zap } from '@lucide/vue'
import { computed } from 'vue'
import { usageBilling, usageBillingText } from '../utils/records'
import UsageDetailPopover from './UsageDetailPopover.vue'
import UsageDetailPopover from '@/components/usage/UsageDetailPopover.vue'
import { usageBillingText } from './shared/presenter'
const props = defineProps<{
record: Pick<UsageDisplayRecord, 'billing'>
record: Pick<UsageListRecord, 'billing'>
}>()
const billing = computed(() => usageBilling(props.record))
const billing = computed(() => props.record.billing)
const billingItems = computed(() => {
const value = billing.value
if (!value)
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usageClientIp } from '../utils/records'
import { usageClientIp } from './shared/presenter'
const props = defineProps<{
record: { clientIp?: string | null }
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { UsageDisplayRecord } from '../utils/records'
import type { UsageListRecord } from '@/api'
import { computed } from 'vue'
import { usageLatencyDetails } from '../utils/records'
import UsageDetailPopover from './UsageDetailPopover.vue'
import UsageDetailPopover from '@/components/usage/UsageDetailPopover.vue'
import { usageLatencyDetails } from './shared/presenter'
const props = defineProps<{
record: Pick<UsageDisplayRecord, 'latencyDetails' | 'firstTokenLatencyMs' | 'latencyMs'>
record: Pick<UsageListRecord, 'latencyDetails' | 'firstTokenLatencyMs' | 'latencyMs'>
}>()
const latencyDetails = computed(() => usageLatencyDetails(props.record))
@@ -1,12 +1,13 @@
<script setup lang="ts">
import type { UsageDisplayRecord } from '../utils/records'
import type { UsageListRecord } from '@/api'
import { CornerDownRight } from '@lucide/vue'
import { computed } from 'vue'
import { usageModelDisplay } from '../utils/records'
import { usageModelDisplay } from './shared/presenter'
const props = defineProps<{
record: UsageDisplayRecord
record: UsageListRecord
}>()
const modelDisplay = computed(() => usageModelDisplay(props.record))
@@ -1,11 +1,11 @@
<script setup lang="ts">
import type { UsageDisplayRecord } from '../utils/records'
import type { UsageListRecord } from '@/api'
import { Bot, SquareDashedBottomCode } from '@lucide/vue'
import { usageIsReview, usageIsSubagent, usageReasoningEffort } from '../utils/records'
import { usageReasoningEffort } from './shared/presenter'
defineProps<{
record: UsageDisplayRecord
record: UsageListRecord
}>()
</script>
@@ -15,7 +15,7 @@ defineProps<{
>
<span>{{ usageReasoningEffort(record) }}</span>
<span
v-if="usageIsSubagent(record)"
v-if="record.subagentKind"
class="inline-flex shrink-0 text-cp-purple-text"
title="子代理请求"
aria-label="子代理请求"
@@ -23,7 +23,7 @@ defineProps<{
<Bot class="size-3.5" stroke-width="2.2" />
</span>
<span
v-if="usageIsReview(record)"
v-if="record.subagentKind === 'review'"
class="inline-flex shrink-0 text-cp-purple-text"
title="Review 子代理请求"
aria-label="Review 子代理请求"
@@ -1,29 +1,28 @@
<script setup lang="ts">
import type { BaseTableColumn } from '@codex-proxy/ui'
import type { UsageDisplayRecord } from '../utils/records'
import type { UsageListRecord } from '@/api'
import { BaseTable } from '@codex-proxy/ui'
import { Minimize2 } from '@lucide/vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import UsageBillingCell from '@/components/usage/UsageBillingCell.vue'
import UsageClientIpCell from '@/components/usage/UsageClientIpCell.vue'
import UsageLatencyCell from '@/components/usage/UsageLatencyCell.vue'
import UsageModelCell from '@/components/usage/UsageModelCell.vue'
import UsageReasoningEffortCell from '@/components/usage/UsageReasoningEffortCell.vue'
import UsageTokenCell from '@/components/usage/UsageTokenCell.vue'
import UsageTransportBadge from '@/components/usage/UsageTransportBadge.vue'
import {
usageAccountText,
usageAuthenticationKind,
usageIsCompact,
usageUserAgent,
} from '../utils/records'
import UsageBillingCell from './UsageBillingCell.vue'
import UsageClientIpCell from './UsageClientIpCell.vue'
import UsageLatencyCell from './UsageLatencyCell.vue'
import UsageModelCell from './UsageModelCell.vue'
import UsageReasoningEffortCell from './UsageReasoningEffortCell.vue'
import UsageTokenCell from './UsageTokenCell.vue'
import UsageTransportBadge from './UsageTransportBadge.vue'
} from './shared/presenter'
// 使用记录表只负责该领域的单元格呈现;筛选与分页由页面组合。
withDefaults(
defineProps<{
columns: BaseTableColumn<UsageDisplayRecord>[]
rows: UsageDisplayRecord[]
columns: BaseTableColumn<UsageListRecord>[]
rows: UsageListRecord[]
loading?: boolean
emptyText?: string
}>(),
@@ -53,7 +52,7 @@ withDefaults(
<template #provider="{ row }">
<ProviderIconGroup
:provider="String(row.provider || '')"
:authentication-kind="usageAuthenticationKind(row)"
:authentication-kind="row.authenticationKind"
/>
</template>
@@ -105,7 +104,7 @@ withDefaults(
<div class="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap">
<code class="font-mono text-cp-sm font-emphasis">{{ row.route || '—' }}</code>
<span
v-if="usageIsCompact(row)"
v-if="row.compact"
class="inline-flex shrink-0 text-cp-orange-text"
title="压缩请求"
aria-label="压缩请求"
@@ -1,16 +1,16 @@
<script setup lang="ts">
import type { UsageDisplayRecord } from '../utils/records'
import type { UsageListRecord } from '@/api'
import { Archive, ArrowDown, ArrowUp } from '@lucide/vue'
import { computed } from 'vue'
import { usageTokenDetails } from '../utils/records'
import UsageDetailPopover from './UsageDetailPopover.vue'
import UsageDetailPopover from '@/components/usage/UsageDetailPopover.vue'
const props = defineProps<{
record: Pick<UsageDisplayRecord, 'tokenDetails'>
record: Pick<UsageListRecord, 'tokenDetails'>
}>()
const tokenDetails = computed(() => usageTokenDetails(props.record))
const tokenDetails = computed(() => props.record.tokenDetails)
const tokenItems = computed(() => [
{ label: '输入 Token', value: tokenDetails.value.inputTokensDisplay },
{ label: '输出 Token', value: tokenDetails.value.outputTokensDisplay },
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { usageTransportType, usageTransportTypeClass } from '../utils/records'
import { usageTransportType, usageTransportTypeClass } from './shared/presenter'
defineProps<{
transport?: string | null
@@ -0,0 +1,35 @@
import type { UsageListRecord } from '@/api'
import { defineTableColumns } from '@codex-proxy/ui'
import { formatProviderLabel } from '@/utils/providers'
export const usageRecordColumns = defineTableColumns<UsageListRecord>([
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
{
key: 'accountEmail',
hideable: false,
label: '账号',
kind: 'identity',
size: '3xl',
},
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
{
key: 'provider',
label: '平台/类型',
kind: 'status',
size: 'md',
fixedWidth: true,
format: (value: unknown) => formatProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'xl' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm', hideable: false },
])
@@ -0,0 +1,68 @@
import type { DashboardHealthStatus } from '@/api'
import { formatInteger } from '@/utils/format'
interface DashboardHealthStatusMeta {
label: string
cellClass: string
badgeClass: string
}
export const healthLegend = [
{ status: 'no_data', label: '无有效样本' },
{ status: 'unavailable', label: '不可达' },
{ status: 'unstable', label: '不稳定' },
{ status: 'low_sample', label: '低样本' },
{ status: 'stable', label: '稳定' },
] satisfies { status: DashboardHealthStatus, label: string }[]
export const healthStatusMeta: Record<DashboardHealthStatus, DashboardHealthStatusMeta> = {
future: {
label: '未来',
cellClass: 'bg-cp-bg-container-disabled opacity-60',
badgeClass: 'bg-cp-fill-tertiary text-cp-text-quaternary',
},
no_data: {
label: '无有效样本',
cellClass: 'bg-cp-border',
badgeClass: 'bg-cp-fill-tertiary text-cp-text-secondary',
},
unavailable: {
label: '不可达',
cellClass: 'bg-cp-error',
badgeClass: 'bg-cp-error-container text-cp-error-on-container',
},
unstable: {
label: '不稳定',
cellClass: 'bg-cp-warning',
badgeClass: 'bg-cp-warning-container text-cp-warning-on-container',
},
low_sample: {
label: '低样本',
cellClass: 'bg-cp-cyan-solid',
badgeClass: 'bg-cp-cyan-container text-cp-cyan-on-container',
},
stable: {
label: '稳定',
cellClass: 'bg-cp-success',
badgeClass: 'bg-cp-success-container text-cp-success-on-container',
},
}
export function healthReliabilityValueClass(successRequests: number, failedRequests: number) {
const eligibleRequests = Math.max(0, successRequests) + Math.max(0, failedRequests)
if (eligibleRequests === 0)
return 'text-cp-text-quaternary'
const reliability = (Math.max(0, successRequests) / eligibleRequests) * 100
if (reliability >= 99.5)
return 'text-cp-success-text'
if (reliability >= 98)
return 'text-cp-cyan-text'
if (reliability >= 95)
return 'text-cp-warning-text'
return 'text-cp-error-text'
}
export function formatHealthCount(value: number) {
return formatInteger(Math.max(0, value))
}
@@ -0,0 +1,154 @@
import type { UsageListRecord, UsageRecordDetail } from '@/api'
import { formatDuration } from '@/utils/format'
// 列表与详情使用独立读模型,展示函数只依赖两者的公共字段。
type UsageCommonRecord = UsageListRecord | UsageRecordDetail
export function usageTransportType(transport?: string | null) {
if (transport === 'websocket')
return 'WS'
if (transport === 'http_sse')
return 'SSE'
if (transport === 'http' || transport === 'http_json')
return 'HTTP'
return transport || '—'
}
export function usageTransportTypeClass(transport?: string | null) {
const type = usageTransportType(transport)
if (type === 'WS')
return 'bg-cp-blue-container text-cp-blue-on-container'
if (type === 'SSE')
return 'bg-cp-green-container text-cp-green-on-container'
return 'bg-cp-fill-tertiary text-cp-text-secondary'
}
export function usageAccountText(record: UsageCommonRecord) {
return record.accountEmail || record.accountName || record.accountId || '—'
}
export function usageClientIp(record: { clientIp?: string | null }) {
return record.clientIp || '—'
}
export function usageUserAgent(record: { userAgent?: string | null }) {
return record.userAgent || '—'
}
export function usageReasoningEffort(record: UsageCommonRecord) {
const reasoningEffort = record.reasoningEffort || '—'
if (record.subagentKind)
return reasoningEffort
return record.reasoningPreset || reasoningEffort
}
export function usageModelDisplay(record: UsageCommonRecord) {
const requestedModel = record.requestedModel || ''
const upstreamModel = record.upstreamModel || ''
const storedModel = record.model || ''
const primary = requestedModel || storedModel || upstreamModel || '—'
const secondary
= upstreamModel && upstreamModel !== primary
? upstreamModel
: requestedModel && storedModel && storedModel !== requestedModel
? storedModel
: ''
const responseModel = record.upstreamResponseModel || ''
const returned = responseModel && responseModel !== primary ? responseModel : ''
const routes = []
if (secondary && secondary !== returned) {
routes.push({
model: secondary,
kind: 'mapped' as const,
description: `网关映射后发送给上游的模型:${secondary}`,
})
}
if (returned) {
routes.push({
model: returned,
kind: 'returned' as const,
description: returned === secondary
? `上游返回模型:${returned}(与网关映射后发送的模型一致)`
: `上游返回模型:${returned}`,
})
}
return { primary, secondary, routes }
}
export function usageLatencyDetails(record: Pick<UsageCommonRecord, 'latencyDetails' | 'firstTokenLatencyMs' | 'latencyMs'>) {
const latencyDetails = record.latencyDetails
const firstTokenMs = durationValue(
record.firstTokenLatencyMs ?? latencyDetails?.firstTokenMs,
)
const firstEventMs = durationValue(latencyDetails?.firstEventMs)
const totalMs = durationValue(record.latencyMs)
const firstReasoningMs = durationValue(latencyDetails?.firstReasoningMs)
const firstTextMs = durationValue(latencyDetails?.firstTextMs)
const breakdownItems = []
if (firstTokenMs !== null && totalMs !== null && firstTokenMs <= totalMs) {
breakdownItems.push({ label: '首字等待', value: formatDuration(firstTokenMs) })
if (firstTextMs !== null && firstTextMs >= firstTokenMs && firstTextMs <= totalMs) {
const beforeTextMs = firstTextMs - firstTokenMs
if (beforeTextMs > 0) {
breakdownItems.push({
label: firstReasoningMs === firstTokenMs ? '推理到正文' : '首个输出到正文',
value: formatDuration(beforeTextMs),
})
}
breakdownItems.push({ label: '正文生成', value: formatDuration(totalMs - firstTextMs) })
}
else {
breakdownItems.push({
label: '首个输出后完成',
value: formatDuration(totalMs - firstTokenMs),
})
}
}
const transportItems = [
{ label: '准入判定', value: durationValue(latencyDetails?.admissionDecisionMs) },
{ label: '账号选择等待', value: durationValue(latencyDetails?.accountSelectionWaitMs) },
{
label: '传输决策等待',
value: durationValue(latencyDetails?.transportDecisionWaitMs),
},
{ label: 'WebSocket 连接', value: durationValue(latencyDetails?.wsConnectMs) },
{ label: '上游响应头', value: durationValue(latencyDetails?.upstreamHeadersMs) },
{ label: '首个上游事件', value: firstEventMs },
{ label: '上游处理', value: durationValue(latencyDetails?.openaiProcessingMs) },
]
.filter(item => item.value !== null)
.map(item => ({ ...item, value: formatDuration(item.value) }))
if (
latencyDetails?.capacityUsedSlots != null
&& latencyDetails.capacityTotalSlots != null
) {
transportItems.push({
label: '账号槽位快照',
value: `${latencyDetails.capacityUsedSlots} / ${latencyDetails.capacityTotalSlots}`,
})
}
return {
// SSE 可能先收到生命周期事件而没有文本或推理增量;这不是首字,须保留原始语义。
firstOutputLabel: firstTokenMs === null && firstEventMs !== null ? '首事件' : '首字',
firstOutputDisplay: formatDuration(firstTokenMs ?? firstEventMs),
totalDisplay: formatDuration(totalMs),
breakdownItems,
transportItems,
}
}
export function usageBillingText(record: Pick<UsageCommonRecord, 'billing'>) {
return record.billing?.totalAmountDisplay || '—'
}
function durationValue(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
@@ -2,18 +2,25 @@ import type { AccountGroup } from '@/api'
import { onMounted, shallowRef } from 'vue'
import { getAccountGroups } from '@/api'
import { useRequestState } from './useRequestState'
export function useAccountGroupCatalog(options: { immediate?: boolean } = {}) {
const groups = shallowRef<AccountGroup[]>([])
const loading = shallowRef(false)
const request = useRequestState()
const { loading } = request
async function loadGroups() {
loading.value = true
const requestId = request.start()
const requestOptions = { signal: request.signal }
try {
const first = await getAccountGroups({ page: 1, pageSize: 200 })
const first = await getAccountGroups({ page: 1, pageSize: 200 }, requestOptions)
if (!request.isCurrent(requestId))
return []
const items = [...first.items]
for (let page = 2; page <= first.page.totalPages; page += 1) {
const result = await getAccountGroups({ page, pageSize: first.page.pageSize })
const result = await getAccountGroups({ page, pageSize: first.page.pageSize }, requestOptions)
if (!request.isCurrent(requestId))
return []
items.push(...result.items)
}
groups.value = items
@@ -23,7 +30,7 @@ export function useAccountGroupCatalog(options: { immediate?: boolean } = {}) {
return []
}
finally {
loading.value = false
request.finish(requestId)
}
}
+3 -2
View File
@@ -21,15 +21,16 @@ function resolveErrorText(error: unknown, errorText: string | false | undefined)
return errorMessage(error, errorText || '操作失败')
}
export function useAsyncAction() {
export function useAsyncAction(defaults: AsyncActionRunOptions = {}) {
const loading = shallowRef(false)
async function run<T>(task: () => MaybePromise<T>, options: AsyncActionRunOptions = {}) {
async function run<T>(task: () => MaybePromise<T>, overrides: AsyncActionRunOptions = {}) {
if (loading.value) {
return undefined
}
loading.value = true
const options = { ...defaults, ...overrides }
try {
const execute = async () => task()
return options.minimumMs === undefined
+13 -5
View File
@@ -1,26 +1,34 @@
import type { OutboundProxyRecord } from '@/api'
import { onMounted, shallowRef } from 'vue'
import { getProxies } from '@/api'
import { useRequestState } from './useRequestState'
export function useProxyCatalog() {
const proxies = shallowRef<OutboundProxyRecord[]>([])
const loading = shallowRef(false)
const request = useRequestState()
const { loading } = request
async function loadProxies() {
if (loading.value)
return
loading.value = true
const requestId = request.start()
const requestOptions = { signal: request.signal }
try {
const first = await getProxies({ page: 1, pageSize: 200 })
const first = await getProxies({ page: 1, pageSize: 200 }, requestOptions)
if (!request.isCurrent(requestId))
return
const items = [...first.items]
for (let page = 2; page <= first.page.totalPages; page += 1) {
items.push(...(await getProxies({ page, pageSize: 200 })).items)
const result = await getProxies({ page, pageSize: first.page.pageSize }, requestOptions)
if (!request.isCurrent(requestId))
return
items.push(...result.items)
}
proxies.value = items
}
catch {}
finally {
loading.value = false
request.finish(requestId)
}
}
@@ -56,6 +56,8 @@ export function useStablePagedQuery<Result extends PageResult>(options: {
}
function reloadFromStart(execution: { silent?: boolean } = {}) {
// 重新分页后旧记录不再对应当前页,失败时也不能留在新页号下。
items.value = []
currentPage.value = 1
total.value = 0
return execute(1, execution)
@@ -34,7 +34,7 @@ import { useAuthStore } from '@/stores/modules/auth'
import { usePluginViewsStore } from '@/stores/modules/plugin-views'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useThemeStore } from '@/stores/modules/theme'
import { pluginPageLocation, shortPluginInstanceId } from '@/views/plugins/utils/navigation'
import { pluginPageLocation, shortPluginInstanceId } from '@/utils/plugin'
const props = withDefaults(
defineProps<{
+1 -1
View File
@@ -10,7 +10,7 @@ import { useUiStore } from '@/stores/modules/ui'
import AppSidebar from './components/AppSidebar.vue'
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
import SystemUpdateModal from './components/system-update-modal/index.vue'
const uiStore = useUiStore()
const systemUpdateStore = useSystemUpdateStore()
+4 -8
View File
@@ -1,9 +1,9 @@
import { API_BASE_URL } from '@/api/constants'
export const CODEX_DEFAULT_MODEL = 'gpt-5.6-terra'
const CODEX_DEFAULT_MODEL = 'gpt-5.6-terra'
export const CODEX_WEBSOCKET_ENABLED_BY_DEFAULT = false
export interface CodexConfigInput {
interface CodexConfigInput {
apiKey: string
baseUrl: string
websocketEnabled?: boolean
@@ -45,7 +45,7 @@ goals = true`
}
}
export interface CodexCcSwitchImportInput {
interface CodexCcSwitchImportInput {
apiKey: string
baseUrl: string
providerName: string
@@ -86,11 +86,7 @@ function buildUsageScript(apiKey: string, baseUrl: string) {
}
export function buildCodexCcSwitchImportDeeplink(input: CodexCcSwitchImportInput): string {
const configFiles = buildCodexConfigFiles({
apiKey: input.apiKey,
baseUrl: input.baseUrl,
websocketEnabled: input.websocketEnabled ?? CODEX_WEBSOCKET_ENABLED_BY_DEFAULT,
})
const configFiles = buildCodexConfigFiles(input)
// 与界面共用原生生图配置;保留 auth 载荷和独立字段,兼容旧版 CCSwitch。
const config = encodeBase64(JSON.stringify({
auth: configFiles.auth,
@@ -1,4 +1,4 @@
export type PresetVisualTone = 'blue' | 'cyan' | 'green' | 'orange'
type PresetVisualTone = 'blue' | 'cyan' | 'green' | 'orange'
const presetVisualTones: readonly PresetVisualTone[] = ['blue', 'green', 'orange', 'cyan']
const presetVisualToneClasses = {
@@ -21,10 +21,7 @@ export function stableVisualIndex(value: unknown, length: number): number {
}
/** 为无状态含义的身份与分类生成稳定色调,不把颜色误解为成功或警告。 */
export function stablePresetVisualTone(value: unknown): PresetVisualTone {
return presetVisualTones[stableVisualIndex(value, presetVisualTones.length)]!
}
export function stablePresetVisualToneClass(value: unknown): string {
return presetVisualToneClasses[stablePresetVisualTone(value)]
const tone = presetVisualTones[stableVisualIndex(value, presetVisualTones.length)]!
return presetVisualToneClasses[tone]
}
+9 -32
View File
@@ -1,45 +1,22 @@
import type { RequestLocation } from '@/api'
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function parseJsonObject(text: string, maximumBytes?: number): Record<string, unknown> {
type JsonObjectResult
= | { value: Record<string, unknown>, error: undefined }
| { value: undefined, error: string }
export function parseJsonObject(text: string, maximumBytes?: number): JsonObjectResult {
let value: unknown
try {
value = JSON.parse(text)
}
catch {
throw new Error('请输入有效的 JSON 对象')
return { value: undefined, error: '请输入有效的 JSON 对象' }
}
if (!isRecord(value))
throw new Error('输入必须是 JSON 对象')
return { value: undefined, error: '输入必须是 JSON 对象' }
if (maximumBytes !== undefined && new TextEncoder().encode(JSON.stringify(value)).byteLength > maximumBytes)
throw new Error(`输入不能超过 ${maximumBytes / 1024} KiB`)
return value
}
export function jsonObjectError(text: string, maximumBytes?: number) {
try {
parseJsonObject(text, maximumBytes)
return undefined
}
catch (error) {
return (error as Error).message
}
}
export function normalizeRequestLocation(location: RequestLocation): RequestLocation {
return {
country: location.country.trim().toUpperCase(),
region: location.region.trim(),
city: location.city.trim(),
timezone: location.timezone.trim(),
}
}
export function requestLocationError(location: RequestLocation): string {
if (!/^[A-Z]{2}$/.test(location.country) || !location.region || !location.city || !location.timezone)
return '请填写两位国家代码、地区、城市和 IANA 时区'
return ''
return { value: undefined, error: `输入不能超过 ${maximumBytes / 1024} KiB` }
return { value, error: undefined }
}
+41 -7
View File
@@ -1,10 +1,3 @@
export function parseTimestamp(value: string | number | Date | null | undefined): number | null {
if (value === null || value === undefined)
return null
const timestamp = new Date(value).getTime()
return Number.isFinite(timestamp) ? timestamp : null
}
const integerFormatter = new Intl.NumberFormat('zh-CN')
const localizedCompactFormatter = new Intl.NumberFormat('zh-CN', {
@@ -44,3 +37,44 @@ export function formatCompactNumber(value: number) {
return formatInteger(normalized)
}
const percentFormatter = new Intl.NumberFormat('zh-CN', {
style: 'percent',
maximumFractionDigits: 1,
})
export function formatPercent(value?: number | null) {
return value == null || !Number.isFinite(value) ? '—' : percentFormatter.format(value)
}
export function formatDuration(value?: number | null) {
if (value == null || !Number.isFinite(value) || value < 0)
return '—'
if (value < 1_000)
return `${Math.round(value)} ms`
if (value < 60_000) {
const seconds = value / 1_000
return `${seconds.toFixed(seconds >= 10 ? 1 : 2).replace(/\.0+$|(?<=\.\d)0$/, '')} s`
}
return `${(value / 60_000).toFixed(1).replace(/\.0$/, '')} min`
}
export function decimalDisplayNumber(value?: string | number | null) {
if (value == null)
return null
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isFinite(parsed) ? parsed : null
}
export function formatUsd(value?: string | number | null, precise = false) {
const parsed = decimalDisplayNumber(value)
if (parsed == null)
return '—'
const fractionDigits = precise || (Math.abs(parsed) > 0 && Math.abs(parsed) < 0.01) ? 4 : 2
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(parsed)
}
+16
View File
@@ -0,0 +1,16 @@
import type { RequestLocation } from '@/api'
export function normalizeRequestLocation(location: RequestLocation): RequestLocation {
return {
country: location.country.trim().toUpperCase(),
region: location.region.trim(),
city: location.city.trim(),
timezone: location.timezone.trim(),
}
}
export function requestLocationError(location: RequestLocation): string {
if (!/^[A-Z]{2}$/.test(location.country) || !location.region || !location.city || !location.timezone)
return '请填写两位国家代码、地区、城市和 IANA 时区'
return ''
}
@@ -1,13 +1,11 @@
import type { PluginManagementPage, PluginManagementView } from '@/api'
export const PLUGIN_PAGE_ROUTE_NAME = 'plugin-page'
export function pluginPageLocation(
view: PluginManagementView,
page: PluginManagementPage,
) {
return {
name: PLUGIN_PAGE_ROUTE_NAME,
name: 'plugin-page',
params: {
instanceId: view.target.instanceId,
pageId: page.id,
+4 -7
View File
@@ -2,26 +2,23 @@ import type { Component } from 'vue'
import { Key, LinkAlt, Openai, Xai } from '@boxicons/vue'
import { Fingerprint, Plug } from '@lucide/vue'
export const PROVIDER_DISPLAY_NAMES = {
const PROVIDER_DISPLAY_NAMES = {
openai: 'OpenAI',
xai: 'xAI',
} as const
export const PROVIDER_IDS = Object.keys(PROVIDER_DISPLAY_NAMES) as Array<keyof typeof PROVIDER_DISPLAY_NAMES>
export type SupportedProvider = keyof typeof PROVIDER_DISPLAY_NAMES
type SupportedProvider = keyof typeof PROVIDER_DISPLAY_NAMES
export function isSupportedProvider(value: unknown): value is SupportedProvider {
return typeof value === 'string' && Object.hasOwn(PROVIDER_DISPLAY_NAMES, value)
}
export function providerDisplayName(value?: string | null) {
return isSupportedProvider(value) ? PROVIDER_DISPLAY_NAMES[value] : undefined
}
export function formatProviderLabel(value?: string | null, fallback = '—') {
const normalized = value?.trim()
return providerDisplayName(normalized?.toLowerCase()) ?? (normalized || fallback)
const provider = normalized?.toLowerCase()
return isSupportedProvider(provider) ? PROVIDER_DISPLAY_NAMES[provider] : (normalized || fallback)
}
export function providerIcon(value: string): Component {
@@ -3,8 +3,8 @@ import type { useAccountConnectionTest } from '../composables/useAccountConnecti
import { BaseButton, BaseIconButton, BaseModal, BaseSelect } from '@codex-proxy/ui'
import { RefreshCw } from '@lucide/vue'
import AccountStatusBadge from './account-status-badge/index.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
import AccountStatusBadge from './AccountStatusBadge/index.vue'
type ConnectionTest = ReturnType<typeof useAccountConnectionTest>
@@ -1,18 +1,18 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
import type { AccountGroup, AccountModelAccess } from '@/api'
import type { Account, AccountGroup, AccountModelAccess } from '@/api'
import { BaseButton, BaseFormItem, BaseModal, BaseSegmented, BaseTextarea } from '@codex-proxy/ui'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { isOpenAiApiKeyAccount, isOpenAiOAuthAccount } from '../utils/upstreamApiKey'
import AccountApiKeyFields from './AccountApiKeyFields.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
import AccountPlanBadge from './AccountPlanBadge.vue'
import AccountSettingsFields from './AccountSettingsFields.vue'
defineProps<{
account: AccountRow | null
account: Account | null
groups: AccountGroup[]
groupsLoading: boolean
saving: boolean
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { getAccounts } from '@/api'
import type { Account } from '@/api'
import { computed } from 'vue'
import { stablePresetVisualToneClass } from '../utils/visualTone'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import { stablePresetVisualToneClass } from '@/utils/color'
import AccountNotesPopover from './AccountNotesPopover.vue'
import AccountPlanBadge from './AccountPlanBadge.vue'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
type AccountIdentity = Pick<AccountRow, 'id' | 'email' | 'planType' | 'planTypeDisplay'>
& Partial<Pick<AccountRow, 'accountId' | 'notes' | 'name' | 'authenticationKind'>>
type AccountIdentity = Pick<Account, 'id' | 'email' | 'planType' | 'planTypeDisplay'>
& Partial<Pick<Account, 'accountId' | 'notes' | 'name' | 'authenticationKind'>>
const props = withDefaults(
defineProps<{
@@ -1,12 +1,13 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import type { Account } from '@/api'
import { BaseIconButton, BaseMenuItem, BasePopover } from '@codex-proxy/ui'
import { Download, KeyRound, MoreHorizontal, Pencil, Power, RefreshCw, RotateCcw, Trash2, Wifi } from '@lucide/vue'
import { computed } from 'vue'
const props = defineProps<{
account: AccountRow
account: Account
deleting: boolean
downloadingCatalog: boolean
recovering: boolean
@@ -15,14 +16,14 @@ const props = defineProps<{
togglingScheduling: boolean
}>()
const emit = defineEmits<{
edit: [account: AccountRow]
delete: [account: AccountRow]
edit: [account: Account]
delete: [account: Account]
recover: [accountId: string]
test: [account: AccountRow]
test: [account: Account]
refresh: [accountId: string]
reauthorize: [account: AccountRow]
downloadModelCatalog: [account: AccountRow]
toggleScheduling: [account: AccountRow]
reauthorize: [account: Account]
downloadModelCatalog: [account: Account]
toggleScheduling: [account: Account]
}>()
const credentialEligible = computed(() => props.account.authenticationKind === 'oauth')
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import type { Account } from '@/api'
import { BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { Sigma } from '@lucide/vue'
@@ -7,10 +7,10 @@ import { computed } from 'vue'
import { modelSuccessRateTextClass } from '../constants'
const props = defineProps<{
account: AccountRow
account: Account
}>()
type AccountModelUsage = AccountRow['usage']['models'][number]
type AccountModelUsage = Account['usage']['models'][number]
const totalBilling = computed(() => props.account.usage.costs.find(cost => cost.currency.toUpperCase() === 'USD'))
const totalBillingDisplay = computed(() => totalBilling.value?.estimatedAmountDisplay ?? '—')
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { AccountCreateSource } from './model'
import type { AccountCreateSource } from '../../utils/accountCreate'
import { BaseSegmented } from '@codex-proxy/ui'
import { LayoutGrid } from '@lucide/vue'
import { computed } from 'vue'
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { AccountCreateForm } from './model'
import type { AccountCreateForm } from '../../utils/accountCreate'
import type { AccountGroup } from '@/api'
import { BaseFormItem, BaseTextarea } from '@codex-proxy/ui'
import AccountSettingsFields from '../AccountSettingsFields.vue'
@@ -1,23 +1,23 @@
<script setup lang="ts">
import type { AccountAuthorizationView } from '../../composables/useAccountAuthorization'
import type { AccountRow } from '../../constants'
import type { AccountCreateForm, AccountImportMode } from './model'
import type { AccountGroup } from '@/api'
import type { AccountCreateForm, AccountImportMode } from '../../utils/accountCreate'
import type { Account, AccountGroup } from '@/api'
import { Openai, Xai } from '@boxicons/vue'
import { BaseButton, BaseIconButton, BaseModal, BaseSegmented } from '@codex-proxy/ui'
import { Copy, LayoutGrid, Settings2 } from '@lucide/vue'
import { computed } from 'vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useCopyText } from '@/composables/useCopyText'
import { accountCreateProvider, accountProxyError } from '../../utils/accountCreate'
import { accountModelAccessError } from '../../utils/modelAccess'
import { parseAccountSchedulingForm } from '../../utils/schedulingForm'
import AccountApiKeyFields from '../AccountApiKeyFields.vue'
import AccountIdentityCell from '../AccountIdentityCell.vue'
import AccountPlanBadge from '../AccountPlanBadge.vue'
import AccountImportFields from './AccountImportFields.vue'
import AccountOAuthFields from './AccountOAuthFields.vue'
import AccountSetupFields from './AccountSetupFields.vue'
import { accountCreateProvider, accountProxyError } from './model'
import { resolveAccountCreatePresentation } from './presenter'
const props = withDefaults(defineProps<{
@@ -26,7 +26,7 @@ const props = withDefaults(defineProps<{
saving?: boolean
oauthLoading?: boolean
reauthorizing?: boolean
account?: AccountRow | null
account?: Account | null
authorization: AccountAuthorizationView
}>(), { saving: false, oauthLoading: false, reauthorizing: false, account: null })
@@ -1,23 +1,8 @@
import type { AccountAuthorizationView } from '../../composables/useAccountAuthorization'
import type { AccountCreateForm } from './model'
import type { AccountCreateForm } from '../../utils/accountCreate'
import { formatProviderLabel } from '@/utils/providers'
import { accountCreateProvider, accountImportModes } from '../../utils/accountCreate'
import { apiKeyAccountError } from '../../utils/upstreamApiKey'
import { accountCreateProvider } from './model'
export function accountImportModes(provider: string | undefined) {
if (provider === 'openai') {
return [
{ label: 'OAuth', value: 'oauth' as const },
{ label: 'API Key', value: 'api_key' as const },
{ label: 'AT', value: 'access_token' as const },
{ label: 'RT', value: 'refresh_token' as const },
{ label: '账号文件', value: 'json' as const },
]
}
if (provider === 'xai')
return [{ label: 'OAuth', value: 'oauth' as const }, { label: '账号文件', value: 'json' as const }]
return []
}
export function resolveAccountCreatePresentation(input: {
form: AccountCreateForm
@@ -1,15 +1,15 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import type { AccountProfileStatisticsResponse } from '@/api'
import type { Account, AccountProfileStatisticsResponse } from '@/api'
import { UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { accountProfileAvatarUrl } from '@/api'
import { stablePresetVisualToneClass } from '../../utils/visualTone'
import AccountPlanBadge from '../AccountPlanBadge.vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import { stablePresetVisualToneClass } from '@/utils/color'
const props = defineProps<{
account: AccountRow
account: Account
profile: AccountProfileStatisticsResponse | null
}>()
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { ProfileActivityLevel, ProfileActivityMode } from '../../utils/accountProfileStatistics'
import type { ProfileActivityLevel, ProfileActivityMode } from './activity'
import type { ProfileActivityCalendar } from '@/api'
import { BaseEmpty, BaseSegmented } from '@codex-proxy/ui'
import { computed, shallowRef } from 'vue'
import { buildProfileActivityGrid, profileActivityCellLabel } from '../../utils/accountProfileStatistics'
import { buildProfileActivityGrid, profileActivityCellLabel } from './activity'
const props = defineProps<{
calendar: ProfileActivityCalendar | null
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import type { Account } from '@/api'
import { BaseButton, BaseEmpty, BaseModal, BaseSkeleton } from '@codex-proxy/ui'
import { RefreshCw, TriangleAlert } from '@lucide/vue'
@@ -12,7 +13,7 @@ import AccountProfileSkeleton from './Skeleton.vue'
import AccountSubscription from './Subscription.vue'
import AccountProfileTokenActivity from './TokenActivity.vue'
const props = defineProps<{ account: AccountRow }>()
const props = defineProps<{ account: Account }>()
const open = defineModel<boolean>({ required: true })
const accountId = toRef(() => props.account.id)
const { profile, subscription, loading, error, load } = useAccountPersonalInfo({
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import type { Account } from '@/api'
import { BaseButton, BaseEmpty, BaseIconButton, BaseModal, BasePopover, BaseSegmented } from '@codex-proxy/ui'
import { ChartNoAxesCombined, CircleAlert, RefreshCw } from '@lucide/vue'
import { useIntervalFn, useNow } from '@vueuse/core'
@@ -11,7 +11,7 @@ import AccountIdentityCell from '../AccountIdentityCell.vue'
import ForecastCapacity from './ForecastCapacity.vue'
import ForecastSkeleton from './Skeleton.vue'
const props = defineProps<{ account: AccountRow }>()
const props = defineProps<{ account: Account }>()
const emit = defineEmits<{ accountUpdated: [account: Account] }>()
const open = defineModel<boolean>({ default: false })
const period = ref('weekly')
@@ -1,13 +1,13 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../../constants'
import type { AccountQuotaWindow } from '@/api'
import { computed } from 'vue'
import AccountRequestTimeline from '../AccountUsageWindow/AccountRequestTimeline.vue'
import AccountRequestTimeline from '@/components/account/account-usage-window/AccountRequestTimeline.vue'
import {
quotaWindowLocalUsageDisplay,
quotaWindowPresentation,
resolveAccountUsageWindowPresentation,
} from '../AccountUsageWindow/presenter'
} from '@/components/account/account-usage-window/presenter'
const props = defineProps<{
label: string | null
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../../constants'
import type { AccountQuotaWindow } from '@/api'
import { computed } from 'vue'
import { quotaWindowPresentation } from '@/components/account/account-usage-window/presenter'
import { orderedPanelQuotaWindows } from '../../constants'
import { quotaWindowPresentation } from '../AccountUsageWindow/presenter'
const props = defineProps<{
windows: AccountQuotaWindow[]
@@ -1,24 +1,25 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import type { Account } from '@/api'
import { BaseEmpty, BaseIconButton } from '@codex-proxy/ui'
import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import { formatProviderLabel } from '@/utils/providers'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
import AccountPlanBadge from '../AccountPlanBadge.vue'
import AccountProfileModal from '../AccountProfileModal/index.vue'
import AccountQuotaForecastModal from '../AccountQuotaForecastModal/index.vue'
import AccountProfileModal from '../account-profile-modal/index.vue'
import AccountQuotaForecastModal from '../account-quota-forecast-modal/index.vue'
import AccountQuotaPanelEntry from './Entry.vue'
import AccountResetCredits from './ResetCredits.vue'
const props = defineProps<{
account: AccountRow
account: Account
refreshing: boolean
}>()
const emit = defineEmits<{
accountUpdated: [account: AccountRow]
accountUpdated: [account: Account]
refreshQuota: [accountId: string]
quotaReset: [accountId: string]
}>()
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../../constants'
import type { AccountQuotaWindow } from '@/api'
import { BasePopover } from '@codex-proxy/ui'
import { computed } from 'vue'
import AccountRequestTimeline from '../AccountUsageWindow/AccountRequestTimeline.vue'
import AccountUsageWindow from '../AccountUsageWindow/index.vue'
import { resolveAccountUsageWindowPresentation } from '../AccountUsageWindow/presenter'
import AccountRequestTimeline from '@/components/account/account-usage-window/AccountRequestTimeline.vue'
import AccountUsageWindow from '@/components/account/account-usage-window/index.vue'
import { resolveAccountUsageWindowPresentation } from '@/components/account/account-usage-window/presenter'
import AccountQuotaWindowGroup from './WindowGroup.vue'
const props = withDefaults(defineProps<{
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../../constants'
import type { AccountQuotaWindow } from '@/api'
import { computed } from 'vue'
import { quotaWindowPresentation } from '../AccountUsageWindow/presenter'
import { quotaWindowPresentation } from '@/components/account/account-usage-window/presenter'
const props = withDefaults(defineProps<{
label: string
@@ -1,16 +1,16 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import type { Account } from '@/api'
import { computed } from 'vue'
import AccountUsageWindow from '@/components/account/account-usage-window/index.vue'
import { quotaWindowPresentation } from '@/components/account/account-usage-window/presenter'
import { groupedAccountQuotaWindows, visibleSummaryQuotaWindows } from '../../constants'
import AccountCapacityIndicator from '../AccountCapacityIndicator.vue'
import AccountUsageWindow from '../AccountUsageWindow/index.vue'
import { quotaWindowPresentation } from '../AccountUsageWindow/presenter'
import AccountQuotaSummaryEntry from './Entry.vue'
import { recentlyUsedQuotaEntry, representativeQuotaWindow } from './presenter'
const props = defineProps<{
account: AccountRow
account: Account
}>()
const quotaWindows = computed(() => props.account.quota.windows)
@@ -1,6 +1,7 @@
import type { AccountQuotaWindow, AccountQuotaWindowEntry, AccountRow } from '../../constants'
import type { AccountQuotaWindowEntry } from '../../constants'
import type { Account, AccountQuotaWindow } from '@/api'
type AccountModelUsage = AccountRow['usage']['models'][number]
type AccountModelUsage = Account['usage']['models'][number]
/**
* 根据账号最近一次模型请求选择对应额度组。
@@ -3,8 +3,8 @@ import type { AccountErrorReason, AccountStatus } from '@/api'
import { BasePopover, BaseScrollbar } from '@codex-proxy/ui'
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import { resolveAccountStatusPresentation } from './presenter'
import { useStatusClock } from './useStatusClock'
const props = withDefaults(
defineProps<{
@@ -30,7 +30,7 @@ const props = withDefaults(
},
)
const now = useUiClock()
const now = useStatusClock()
const presentation = computed(() =>
resolveAccountStatusPresentation({
status: props.status,
@@ -2,10 +2,9 @@ import type { Component } from 'vue'
import type { AccountErrorReason, AccountStatus } from '@/api'
import { AlertTriangle, CircleCheck, Gauge, Power, Timer } from '@lucide/vue'
import { parseTimestamp } from '@/utils/format'
import { errorReasonLabels, statusLabels, statusTones } from '../../constants'
export type AccountStatusDisplayMode = AccountStatus | 'refresh_backoff'
type AccountStatusDisplayMode = AccountStatus | 'refresh_backoff'
type AccountStatusTone = (typeof statusTones)[AccountStatus]
@@ -25,7 +24,7 @@ interface AccountStatusStyle {
icon: string
}
export interface AccountStatusPresentation {
interface AccountStatusPresentation {
mode: AccountStatusDisplayMode
statusStyle: AccountStatusStyle
label: string
@@ -41,7 +40,7 @@ export interface AccountStatusPresentation {
triggerLabel: string
}
export interface AccountStatusPresentationInput {
interface AccountStatusPresentationInput {
status: AccountStatus
errorReason: AccountErrorReason | null
errorMessage: string | null
@@ -143,12 +142,9 @@ const errorRecoveryHints: Record<AccountErrorReason, string> = {
export function resolveAccountStatusPresentation(
input: AccountStatusPresentationInput,
): AccountStatusPresentation {
const nextRefreshTimestamp = input.nextRefreshAt === null
? null
: parseTimestamp(input.nextRefreshAt)
const isBackoff = input.status !== 'disabled'
&& nextRefreshTimestamp !== null
&& nextRefreshTimestamp > input.now
&& input.nextRefreshAt !== null
&& Date.parse(input.nextRefreshAt) > input.now
const mode: AccountStatusDisplayMode = isBackoff ? 'refresh_backoff' : input.status
const isFreeze = mode === 'rate_limited' && input.rateLimitReason === 'capacity_freeze'
const waitsForProbe = isFreeze && input.recoveryProbeRequired
@@ -1,6 +1,6 @@
import { createSharedComposable, useIntervalFn, useNow } from '@vueuse/core'
export const useUiClock = createSharedComposable(() =>
export const useStatusClock = createSharedComposable(() =>
useNow({
scheduler: callback => useIntervalFn(callback, 30_000),
}),
@@ -1,5 +1,5 @@
import type { Ref } from 'vue'
import type { AccountModelAccess, getAccounts } from '@/api'
import type { Account, AccountModelAccess } from '@/api'
import { toast } from '@codex-proxy/ui'
import { computed, ref, shallowRef, watch } from 'vue'
@@ -8,15 +8,13 @@ import { useAsyncAction } from '@/composables/useAsyncAction'
import { accountModelAccessError } from '../utils/modelAccess'
import { concurrencyLimitInput, parseAccountSchedulingForm } from '../utils/schedulingForm'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export function useAccountBatchEditor(options: {
accounts: Ref<AccountRow[]>
accounts: Ref<Account[]>
selectedIds: Ref<Set<string>>
reloadAccounts: () => Promise<unknown>
reloadGroups: () => Promise<unknown>
}) {
const selectedAccountsById = new Map<string, AccountRow>()
const selectedAccountsById = new Map<string, Account>()
const showBatchEditModal = shallowRef(false)
const editingCount = shallowRef(0)
const schedulingEnabled = shallowRef(true)
@@ -143,19 +141,19 @@ export function useAccountBatchEditor(options: {
}
}
function sharedConcurrencyLimit(accounts: AccountRow[]) {
function sharedConcurrencyLimit(accounts: Account[]) {
const first = accounts[0]?.concurrencyLimit ?? null
return accounts.every(account => account.concurrencyLimit === first)
? concurrencyLimitInput(first)
: ''
}
function sharedWeight(accounts: AccountRow[]) {
function sharedWeight(accounts: Account[]) {
const first = accounts[0]?.weight ?? 1
return accounts.every(account => account.weight === first) ? String(first) : '1'
}
function sharedGroupIds(accounts: AccountRow[]) {
function sharedGroupIds(accounts: Account[]) {
const [first, ...rest] = accounts
if (!first)
return []
@@ -1,4 +1,4 @@
import type { AccountModelAccess, ApiKeyConfiguration, getAccounts } from '@/api'
import type { Account, AccountModelAccess, ApiKeyConfiguration } from '@/api'
import { toast } from '@codex-proxy/ui'
import { ref, shallowRef, watch } from 'vue'
@@ -9,15 +9,13 @@ import { accountModelAccessError } from '../utils/modelAccess'
import { concurrencyLimitInput, parseAccountSchedulingForm } from '../utils/schedulingForm'
import { apiKeyAccountError, emptyApiKeyAccountForm, isOpenAiApiKeyAccount, isOpenAiOAuthAccount, parseApiKeyConfiguration } from '../utils/upstreamApiKey'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export function useAccountEditor(options: {
reloadAccounts: () => Promise<unknown>
reloadGroups: () => Promise<unknown>
}) {
const showEditModal = shallowRef(false)
// 保存后列表可能因筛选移除该账号,编辑窗口的退场仍需保留原账号内容。
const editingAccount = shallowRef<AccountRow | null>(null)
const editingAccount = shallowRef<Account | null>(null)
const notes = shallowRef('')
const schedulingEnabled = shallowRef(true)
const concurrencyLimit = shallowRef('')
@@ -66,7 +64,7 @@ export function useAccountEditor(options: {
}
}
function open(account: AccountRow) {
function open(account: Account) {
configurationRequest.invalidate()
editingAccount.value = account
notes.value = account.notes ?? ''
@@ -1,5 +1,5 @@
import type { Ref } from 'vue'
import type { AccountImportTask, getAccounts } from '@/api'
import type { Account, AccountImportTask } from '@/api'
import type { RequestOptions } from '@/api/request'
import { toast } from '@codex-proxy/ui'
import { computed, ref, shallowReactive, watch } from 'vue'
@@ -20,14 +20,12 @@ import { isSupportedProvider } from '@/utils/providers'
import { useAccountOnboarding } from './useAccountOnboarding'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export function useAccountMutations(options: {
accounts: Ref<AccountRow[]>
accounts: Ref<Account[]>
selectedIds: Ref<Set<string>>
onImportTaskCreated: (task: AccountImportTask) => void
reload: () => Promise<unknown>
replaceAccount: (account: AccountRow) => Promise<boolean>
replaceAccount: (account: Account) => Promise<boolean>
}) {
const loadAccounts = options.reload
const { downloadJson } = useDownload()
@@ -35,10 +33,10 @@ export function useAccountMutations(options: {
reload: loadAccounts,
onImportTaskCreated: options.onImportTaskCreated,
})
const selectedAccountsById = shallowReactive(new Map<string, AccountRow>())
const selectedAccountsById = shallowReactive(new Map<string, Account>())
const showDeleteModal = ref(false)
const showSingleDeleteModal = ref(false)
const pendingDeleteAccount = ref<AccountRow | null>(null)
const pendingDeleteAccount = ref<Account | null>(null)
const deleteCount = ref(0)
const recoveringAccounts = useIdSet<string>()
const refreshingAccounts = useIdSet<string>()
@@ -88,7 +86,7 @@ export function useAccountMutations(options: {
{ immediate: true, flush: 'sync' },
)
function requestDeleteAccount(account: AccountRow) {
function requestDeleteAccount(account: Account) {
pendingDeleteAccount.value = account
showSingleDeleteModal.value = true
}
@@ -173,7 +171,7 @@ export function useAccountMutations(options: {
)
}
async function handleDownloadModelCatalog(account: AccountRow) {
async function handleDownloadModelCatalog(account: Account) {
await downloadingCatalogAccounts.run(account.id, async () => {
try {
const result = await getAccountModelCatalog({ accountId: account.id })
@@ -209,7 +207,7 @@ export function useAccountMutations(options: {
})
}
async function handleToggleScheduling(account: AccountRow) {
async function handleToggleScheduling(account: Account) {
await togglingSchedulingAccounts.run(account.id, async () => {
try {
await batchUpdateAccounts({ accountIds: [account.id], enabled: !account.enabled })
@@ -278,7 +276,7 @@ export function useAccountMutations(options: {
return accounts
}
async function deleteAccountBatch(accounts: AccountRow[], options?: RequestOptions) {
async function deleteAccountBatch(accounts: Account[], options?: RequestOptions) {
const account = accounts[0]
if (!account)
return
@@ -291,8 +289,8 @@ export function useAccountMutations(options: {
throw new Error(`不支持的 Provider:${account.provider}`)
}
function accountDeletionGroups(accounts: AccountRow[]) {
const groups = new Map<string, AccountRow[]>()
function accountDeletionGroups(accounts: Account[]) {
const groups = new Map<string, Account[]>()
for (const account of accounts) {
const key = account.provider
const group = groups.get(key)
@@ -1,24 +1,21 @@
import type { AccountImportTask, getAccounts } from '@/api'
import type { Account, AccountImportTask } from '@/api'
import { toast } from '@codex-proxy/ui'
import { computed, ref, shallowRef, watch } from 'vue'
import { createAccountImportTask, importAccounts } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage, generateRequestId } from '@/utils/operation'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
import { accountCreateProvider, accountCreateSourceKey, accountImportSettings, accountProxyError, emptyAccountCreateForm } from '../components/AccountCreateModal/model'
import { accountImportModes } from '../components/AccountCreateModal/presenter'
import { accountCreateProvider, accountCreateSourceKey, accountImportModes, accountImportSettings, accountProxyError, emptyAccountCreateForm } from '../utils/accountCreate'
import { accountImportDocuments, MAX_ACCOUNT_IMPORT_COUNT, mixedImportDocuments } from '../utils/accountImport'
import { apiKeyAccountError, emptyApiKeyAccountForm } from '../utils/upstreamApiKey'
import { useAccountAuthorization } from './useAccountAuthorization'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export function useAccountOnboarding(options: {
reload: () => Promise<unknown>
onImportTaskCreated: (task: AccountImportTask) => void
}) {
const createModalOpen = shallowRef(false)
const reauthorizingAccount = shallowRef<AccountRow | null>(null)
const reauthorizingAccount = shallowRef<Account | null>(null)
const creatingAccountAction = useAsyncAction()
const createForm = ref(emptyAccountCreateForm())
const authorization = useAccountAuthorization(() => finishCreate(reauthorizingAccount.value ? '账号重新授权成功' : '账号已添加'))
@@ -133,7 +130,7 @@ export function useAccountOnboarding(options: {
showCreateModal.value = true
}
function openReauthorizeAccount(account: AccountRow) {
function openReauthorizeAccount(account: Account) {
if (isSupportedProvider(account.provider) && account.authenticationKind !== 'oauth')
return
authorization.reset()
@@ -1,12 +1,10 @@
import type { BaseTableSort } from '@codex-proxy/ui'
import { watchDebounced } from '@vueuse/core'
import type { Account } from '@/api'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getAccounts } from '@/api'
import { usePagedQuery } from '@/composables/usePagedQuery'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export function useAccountsQuery() {
const searchQuery = shallowRef('')
const providerQuery = shallowRef('')
@@ -63,7 +61,7 @@ export function useAccountsQuery() {
void query.execute()
}
async function replaceAccount(updated: AccountRow) {
async function replaceAccount(updated: Account) {
// 先取消旧查询并应用接口返回的账号,避免旧响应覆盖最新行数据。
query.invalidate()
query.items.value = query.items.value.map(account => account.id === updated.id ? updated : account)
@@ -74,14 +72,13 @@ export function useAccountsQuery() {
return query.items.value.some(account => account.id === updated.id)
}
watchDebounced(
searchQuery,
() => {
watch(searchQuery, (_value, _previous, onCleanup) => {
const timer = setTimeout(() => {
query.page.value = 1
void query.execute()
},
{ debounce: 250 },
)
}, 250)
onCleanup(() => clearTimeout(timer))
})
watch([providerQuery, statusQuery, groupQuery], () => {
query.page.value = 1
@@ -1,13 +1,11 @@
import type { Ref } from 'vue'
import type { getAccounts } from '@/api'
import type { Account } from '@/api'
import { computed, ref } from 'vue'
import { usePageSelection } from '@/composables/usePageSelection'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export function useAccountsTable(
accounts: Ref<AccountRow[]>,
accounts: Ref<Account[]>,
selectedIds = ref<Set<string>>(new Set()),
) {
const expandedAccountIds = ref<Set<string>>(new Set())
+3 -18
View File
@@ -1,10 +1,7 @@
import type { AccountErrorReason, AccountStatus, getAccounts } from '@/api'
import type { Account, AccountErrorReason, AccountQuotaWindow, AccountStatus } from '@/api'
import { defineTableColumns } from '@codex-proxy/ui'
import { formatProviderLabel } from '@/utils/providers'
export type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export type AccountQuotaWindow = AccountRow['quota']['windows'][number]
export interface AccountQuotaWindowEntry {
key: string
label: string | null
@@ -17,7 +14,7 @@ const quotaGroupOrder = new Map([
['other', 2],
])
export const accountColumns = defineTableColumns<AccountRow>([
export const accountColumns = defineTableColumns<Account>([
{ key: 'expander', kind: 'expander', hideable: false },
{ key: 'selection', kind: 'selection', hideable: false },
{
@@ -34,7 +31,7 @@ export const accountColumns = defineTableColumns<AccountRow>([
kind: 'meta',
size: 'md',
align: 'center',
format: value => accountProviderLabel(typeof value === 'string' ? value : null),
format: value => formatProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'status', label: '状态', kind: 'status', align: 'left', sortable: true },
{ key: 'planType', label: '订阅', kind: 'status', sortable: true },
@@ -92,14 +89,6 @@ export const errorReasonLabels: Record<AccountErrorReason, string> = {
account_banned: '账号不可用或已被封禁',
}
/**
* 后端已派生互斥状态(正常 / 配额耗尽 / 限流中 / 已停用 / 错误);
* 前端只渲染,不再独立派生。
*/
export function derivedAccountStatus(row: AccountRow): AccountStatus {
return row.status
}
export function visibleSummaryQuotaWindows(windows: AccountQuotaWindow[]) {
const known = [...windows]
.filter(window => window.group !== 'other')
@@ -199,10 +188,6 @@ function quotaLimitLabel(window: AccountQuotaWindow) {
return window.limitName
}
function accountProviderLabel(value?: string | null) {
return formatProviderLabel(value)
}
function optionalAccountCell(value: unknown) {
return value === '—' || value === '-' ? '' : value
}
+8 -8
View File
@@ -3,22 +3,22 @@ import { BaseCard, BaseCheckbox, BaseConfirmModal, BasePageHeader, BaseTable, Ba
import { ChevronDown } from '@lucide/vue'
import { ref } from 'vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import AccountGroupMarks from '@/components/AccountGroupMarks.vue'
import LastUsedAtCell from '@/components/LastUsedAtCell.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useAccountGroupCatalog } from '@/composables/useAccountGroupCatalog'
import AccountCreateModal from './components/account-create-modal/index.vue'
import AccountImportTasks from './components/account-import-tasks/index.vue'
import AccountQuotaPanel from './components/account-quota-panel/index.vue'
import AccountQuotaSummaryCell from './components/account-quota-summary-cell/index.vue'
import AccountStatusBadge from './components/account-status-badge/index.vue'
import AccountBatchEditModal from './components/AccountBatchEditModal.vue'
import AccountConnectionTestModal from './components/AccountConnectionTestModal.vue'
import AccountCreateModal from './components/AccountCreateModal/index.vue'
import AccountEditModal from './components/AccountEditModal.vue'
import AccountFilters from './components/AccountFilters.vue'
import AccountIdentityCell from './components/AccountIdentityCell.vue'
import AccountImportTasks from './components/AccountImportTasks/index.vue'
import AccountOverviewCards from './components/AccountOverviewCards.vue'
import AccountPlanBadge from './components/AccountPlanBadge.vue'
import AccountQuotaPanel from './components/AccountQuotaPanel/index.vue'
import AccountQuotaSummaryCell from './components/AccountQuotaSummaryCell/index.vue'
import AccountStatusBadge from './components/AccountStatusBadge/index.vue'
import AccountTableActions from './components/AccountTableActions.vue'
import AccountUsagePanel from './components/AccountUsagePanel.vue'
import { useAccountBatchEditor } from './composables/useAccountBatchEditor'
@@ -28,7 +28,7 @@ import { useAccountImportTasks } from './composables/useAccountImportTasks'
import { useAccountMutations } from './composables/useAccountMutations'
import { useAccountsQuery } from './composables/useAccountsQuery'
import { useAccountsTable } from './composables/useAccountsTable'
import { accountColumns, derivedAccountStatus } from './constants'
import { accountColumns } from './constants'
const selectedIds = ref<Set<string>>(new Set())
const { visibleColumns, columnOptions, setColumnVisible, setColumnOrder, resetColumns } = useTableColumns(accountColumns, 'accounts')
@@ -294,7 +294,7 @@ const {
<template #status="{ row }">
<AccountStatusBadge
:status="derivedAccountStatus(row)"
:status="row.status"
:error-reason="row.errorReason"
:error-message="row.errorMessage"
:rate-limit-recovery-display="row.quota.rateLimitRecoveryDisplay"
@@ -1,12 +1,12 @@
import type { ApiKeyAccountForm } from '../../utils/upstreamApiKey'
import type { ApiKeyAccountForm } from './upstreamApiKey'
import type { AccountModelAccess } from '@/api'
import { accountModelAccessError } from '../../utils/modelAccess'
import { parseAccountSchedulingForm } from '../../utils/schedulingForm'
import { emptyApiKeyAccountForm } from '../../utils/upstreamApiKey'
import { accountModelAccessError } from './modelAccess'
import { parseAccountSchedulingForm } from './schedulingForm'
import { emptyApiKeyAccountForm } from './upstreamApiKey'
export type AccountCreateSource = { kind: 'bundle' } | { kind: 'provider', id: string }
export type AccountImportMode = 'oauth' | 'api_key' | 'access_token' | 'refresh_token' | 'json'
export type AccountImportInputMode = 'access_token' | 'refresh_token' | 'json'
type AccountImportInputMode = 'access_token' | 'refresh_token' | 'json'
export interface AccountCreateForm {
source: AccountCreateSource | null
@@ -72,3 +72,18 @@ export function accountImportSettings(form: AccountCreateForm) {
notes: form.notes.trim() || undefined,
}
}
export function accountImportModes(provider: string | undefined) {
if (provider === 'openai') {
return [
{ label: 'OAuth', value: 'oauth' as const },
{ label: 'API Key', value: 'api_key' as const },
{ label: 'AT', value: 'access_token' as const },
{ label: 'RT', value: 'refresh_token' as const },
{ label: '账号文件', value: 'json' as const },
]
}
if (provider === 'xai')
return [{ label: 'OAuth', value: 'oauth' as const }, { label: '账号文件', value: 'json' as const }]
return []
}
@@ -2,7 +2,7 @@ import { isRecord } from '@/utils/data'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
export const MAX_ACCOUNT_IMPORT_COUNT = 200
export interface AccountImportDocument {
interface AccountImportDocument {
provider: string
document: Record<string, unknown>
}
@@ -1,4 +1,4 @@
export interface AccountSchedulingValues {
interface AccountSchedulingValues {
concurrencyLimit: number | null
weight: number
}
@@ -1,11 +1,11 @@
<script setup lang="ts">
import type { dashboardSnapshotView, MetricTone } from '../composables/useDashboard'
import type { dashboardSnapshotView, MetricTone } from '../presenter'
import { BaseCard, BaseEmpty } from '@codex-proxy/ui'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
import AccountUsageWindow from '@/components/account/account-usage-window/index.vue'
import { formatCompactNumber } from '@/utils/format'
import AccountUsageWindow from '@/views/accounts/components/AccountUsageWindow/index.vue'
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
import DashboardAccountIdentity from './DashboardAccountIdentity.vue'
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { dashboardSnapshotView } from '../composables/useDashboard'
import type { dashboardSnapshotView } from '../presenter'
import { computed } from 'vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import { stablePresetVisualToneClass } from '@/utils/color'
import { authenticationIcon, formatAuthenticationLabel, formatProviderLabel, providerIcon } from '@/utils/providers'
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
import { stablePresetVisualToneClass } from '@/views/accounts/utils/visualTone'
type DashboardSnapshot = ReturnType<typeof dashboardSnapshotView>
type DashboardAccount = DashboardSnapshot['accountUsage'][number]
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { dashboardSnapshotView, dashboardTrendView } from '../composables/useDashboard'
import type { dashboardSnapshotView, dashboardTrendView } from '../presenter'
import type { DashboardTrendKind } from '@/api/modules/dashboard'
import { BaseIconButton, BasePageHeader } from '@codex-proxy/ui'
import { RefreshCw } from '@lucide/vue'
import RequestHealthTimelineCard from '@/components/usage/RequestHealthTimelineCard.vue'
import AccountOverviewCard from './AccountOverviewCard.vue'
import DashboardHeartbeat from './DashboardHeartbeat.vue'
import MetricCard from './MetricCard.vue'
import RequestHealthTimelineCard from './RequestHealthTimelineCard.vue'
import RequestTrendCard from './RequestTrendCard.vue'
import UsageRecordCard from './UsageRecordCard.vue'
import WireProfileCard from './WireProfileCard.vue'
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import type { MetricCardView, MetricTone } from '../composables/useDashboard'
import type { MetricCardView, MetricTone } from '../presenter'
import { BaseCard, BaseMotionIcon } from '@codex-proxy/ui'
import { computed } from 'vue'
import AnimatedMetricValue from '@/components/AnimatedMetricValue.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useThemeColor } from '@/composables/useThemeColor'
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
import AnimatedMetricValue from './AnimatedMetricValue.vue'
const props = defineProps<{
metric: MetricCardView
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { dashboardTrendView, normalizeDashboardTrendKind } from '../composables/useDashboard'
import type { dashboardTrendView, normalizeDashboardTrendKind } from '../presenter'
import { BaseCard, BaseEmpty, BaseSegmented } from '@codex-proxy/ui'
import { toRef } from 'vue'
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { dashboardSnapshotView } from '../composables/useDashboard'
import type { dashboardSnapshotView } from '../presenter'
import { BaseCard } from '@codex-proxy/ui'
import UsageRecordsTable from '@/views/usage/components/UsageRecordsTable.vue'
import { usageRecordColumns } from '@/views/usage/constants'
import { usageRecordColumns } from '@/components/usage/shared/columns'
import UsageRecordsTable from '@/components/usage/UsageRecordsTable.vue'
type DashboardSnapshot = ReturnType<typeof dashboardSnapshotView>
@@ -1,16 +1,15 @@
import type { Component } from 'vue'
import { Activity, FileText, Timer, Users } from '@lucide/vue'
import type { DashboardTrendResponse } from '@/api'
import { useIntervalFn } from '@vueuse/core'
import { computed, onMounted, onScopeDispose, shallowRef } from 'vue'
import { getDashboardSummary, getDashboardTrend } from '@/api'
import { formatCompactNumber, formatInteger } from '@/utils/format'
import { errorMessage, withMinimumDuration } from '@/utils/operation'
import { dashboardSnapshotView, dashboardTrendView, normalizeDashboardTrendKind } from '../presenter'
export function useDashboard() {
const activeTrendKind = shallowRef(normalizeDashboardTrendKind('usage'))
const snapshot = shallowRef(dashboardSnapshotView(null))
const trend = shallowRef<DashboardTrend | null>(null)
const trend = shallowRef<DashboardTrendResponse | null>(null)
const trendLoading = shallowRef(false)
const trendError = shallowRef('')
const loading = shallowRef(false)
@@ -176,374 +175,3 @@ export function useDashboard() {
loadTrend,
}
}
type DashboardSummary = Awaited<ReturnType<typeof getDashboardSummary>>
type DashboardTrend = Awaited<ReturnType<typeof getDashboardTrend>>
type DashboardTrendPoint = DashboardTrend['points'][number]
type DashboardTrendKind = DashboardTrend['kind']
export type MetricTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
export interface MetricCardView {
title: string
value: string
valueRaw?: number | null
valueFormatter?: (value: number) => string
icon: Component
tone: MetricTone
sparkline?: {
values: number[]
tone: MetricTone
}
trend?: {
direction: 'up' | 'down' | 'flat'
tone: MetricTone
}
details: Array<{
label: string
value: string
tone?: MetricTone
}>
}
const metricSparklineBuckets = 12
const emptyCards: DashboardSummary['cards'] = {
credentials: {
total: '0',
totalValue: 0,
available: '0',
availableValue: 0,
unavailable: '0',
unavailableValue: 0,
},
traffic: {
todayRequests: '0',
todayRequestsValue: 0,
yesterdayRequestsValue: 0,
totalRequests: '0',
},
tokens: {
todayTokens: '0',
todayTokensValue: 0,
yesterdayTokensValue: 0,
totalTokens: '0',
totalBillingAmountUsd: '—',
},
cache: {
todayHitRate: '—',
todayHitRateValue: null,
yesterdayHitRateValue: null,
totalHitRate: '—',
totalCachedTokens: '0',
averageFirstTokenLatencyMs: '—',
},
}
const emptyHealthTimeline: DashboardSummary['healthTimeline'] = {
title: '请求健康时间线',
description: '有效请求可用性',
reliabilityDisplay: '-',
status: 'no_data',
successRequests: 0,
failedRequests: 0,
cancelledRequests: 0,
incompleteRequests: 0,
callerErrorRequests: 0,
points: [],
}
const emptyCapacityInfo = {
maxConcurrentPerAccount: null,
totalSlots: null,
usedSlots: null,
availableSlots: null,
}
export function dashboardSnapshotView(summary: DashboardSummary | null) {
const trendPoints = summary?.trend.points ?? []
return {
metrics: metricCards(summary?.cards ?? emptyCards, trendPoints),
healthTimeline: summary?.healthTimeline ?? emptyHealthTimeline,
accountUsage: (summary?.accountUsage ?? []),
wireProfiles: summary?.wireProfiles ?? [],
usageRecords: summary?.usageRecords ?? [],
poolSummary: summary?.poolSummary ?? null,
capacityInfo: summary?.capacityInfo ?? emptyCapacityInfo,
rotationStrategy: summary?.rotationStrategy ?? null,
}
}
export function dashboardTrendView(trend: DashboardTrend | null) {
if (!trend) {
const points: ReturnType<typeof aggregateUsageTrend> = []
const summary: ReturnType<typeof usageTrendSummary> = []
return { points, summary }
}
if (trend.kind === 'usage') {
const points = aggregateUsageTrend(trend.points)
return { points, summary: usageTrendSummary(points) }
}
const summary = []
for (const item of trend.summary) {
summary.push({
label: item.label,
value: trend.kind === 'errors' && item.ratio !== null ? item.ratio : item.value,
tone: trendSummaryTone(item.label),
colorVar: trendSummaryColorVar(trend.kind, item.label),
})
}
return { points: trend.points, summary }
}
export function normalizeDashboardTrendKind(kind: string): DashboardTrendKind {
if (kind === 'latency' || kind === 'errors')
return kind
return 'usage'
}
function metricCards(
cards: DashboardSummary['cards'],
points: DashboardTrendPoint[],
): MetricCardView[] {
const { credentials, traffic, tokens, cache } = cards
const recentPoints = recentTrendWindow(points)
return [
{
title: '账号',
value: credentials.total,
valueRaw: credentials.totalValue,
valueFormatter: formatAccountCount,
icon: Users,
tone: 'normal',
details: [
{
label: '可用',
value: credentials.available,
tone: 'success',
},
{
label: '不可用',
value: credentials.unavailable,
tone: 'danger',
},
],
},
{
title: '请求次数',
value: traffic.todayRequests,
valueRaw: traffic.todayRequestsValue,
valueFormatter: formatCompactNumber,
icon: Activity,
tone: 'info',
sparkline: sparkline(
recentPoints.map(point => point.requestsValue),
'info',
),
trend: trendState(traffic.todayRequestsValue, traffic.yesterdayRequestsValue, 'info'),
details: [
{ label: '总请求', value: traffic.totalRequests, tone: 'info' },
{ label: '首字均值', value: cache.averageFirstTokenLatencyMs, tone: 'info' },
],
},
{
title: 'Token',
value: tokens.todayTokens,
valueRaw: tokens.todayTokensValue,
valueFormatter: formatCompactNumber,
icon: FileText,
tone: 'success',
sparkline: sparkline(
recentPoints.map(point => point.tokensValue),
'success',
),
trend: trendState(tokens.todayTokensValue, tokens.yesterdayTokensValue, 'success'),
details: [
{ label: '总 Token', value: tokens.totalTokens, tone: 'success' },
{ label: '总计费', value: formatDashboardUsd(tokens.totalBillingAmountUsd), tone: 'success' },
],
},
{
title: '缓存命中',
value: cache.todayHitRate,
valueRaw: cache.todayHitRateValue,
valueFormatter: formatDashboardRate,
icon: Timer,
tone: cache.todayHitRateValue && cache.todayHitRateValue > 0 ? 'warning' : 'normal',
sparkline: sparkline(
recentPoints.map(point => point.cacheHitRateValue),
'warning',
),
trend: trendState(
cache.todayHitRateValue ?? 0,
cache.yesterdayHitRateValue ?? 0,
'warning',
),
details: [
{ label: '总缓存命中', value: cache.totalHitRate, tone: 'warning' },
{ label: '总缓存', value: cache.totalCachedTokens, tone: 'warning' },
],
},
]
}
function formatAccountCount(value: number) {
return formatInteger(Math.max(0, Math.round(value)))
}
function sparkline(values: number[], tone: MetricTone) {
return values.some(value => value > 0) ? { values, tone } : undefined
}
function recentTrendWindow(points: DashboardTrendPoint[]) {
let lastActiveIndex = points.length - 1
while (lastActiveIndex >= 0 && points[lastActiveIndex].requestsValue <= 0) lastActiveIndex -= 1
if (lastActiveIndex < 0)
return []
return points.slice(
Math.max(0, lastActiveIndex - (metricSparklineBuckets - 1)),
lastActiveIndex + 1,
)
}
function formatDashboardRate(value: number) {
return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '—'
}
function aggregateUsageTrend(points: DashboardTrendPoint[]) {
return Array.from({ length: Math.ceil(points.length / 2) }, (_, groupIndex) => {
const group = points.slice(groupIndex * 2, groupIndex * 2 + 2)
const first = group[0]
const requestsValue = sum(group, point => point.requestsValue)
const errorsValue = sum(group, point => point.errorsValue)
const inputTokensValue = sum(group, point => point.inputTokensValue)
const outputTokensValue = sum(group, point => point.outputTokensValue)
const cachedTokensValue = Math.min(
inputTokensValue,
sum(group, point => point.cachedTokensValue),
)
const uncachedInputTokensValue = inputTokensValue - cachedTokensValue
const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : 0
const successRateValue = requestsValue
? ((requestsValue - errorsValue) / requestsValue) * 100
: 0
return {
...first,
requests: formatCompactNumber(requestsValue),
requestsValue,
inputTokens: formatCompactNumber(inputTokensValue),
inputTokensValue,
outputTokens: formatCompactNumber(outputTokensValue),
outputTokensValue,
cachedTokens: formatCompactNumber(cachedTokensValue),
cachedTokensValue,
uncachedInputTokens: formatCompactNumber(uncachedInputTokensValue),
uncachedInputTokensValue,
effectiveTokens: formatCompactNumber(effectiveTokensValue),
effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null,
cacheHitRate: formatDashboardRate(cacheHitRateValue),
cacheHitRateValue,
tokensValue: inputTokensValue + outputTokensValue,
errors: formatCompactNumber(errorsValue),
errorsValue,
successRate: `${successRateValue.toFixed(1)}%`,
successRateValue,
}
})
}
function sum(points: DashboardTrendPoint[], selector: (point: DashboardTrendPoint) => number) {
return points.reduce((total, point) => total + selector(point), 0)
}
function usageTrendSummary(points: ReturnType<typeof aggregateUsageTrend>) {
const inputTokens = points.reduce((total, point) => total + point.inputTokensValue, 0)
const outputTokens = points.reduce((total, point) => total + point.outputTokensValue, 0)
const cachedTokens = Math.min(
inputTokens,
points.reduce((total, point) => total + point.cachedTokensValue, 0),
)
return [
{
label: '输入',
value: formatCompactNumber(inputTokens),
tone: 'info',
colorVar: '--cp-color-blue-solid',
},
{
label: '输出',
value: formatCompactNumber(outputTokens),
tone: 'success',
colorVar: '--cp-color-green-solid',
},
{
label: '缓存',
value: formatCompactNumber(cachedTokens),
tone: 'normal',
colorVar: '--cp-color-text-tertiary',
},
]
}
function formatDashboardUsd(value: string) {
const normalized = value.trim()
if (!normalized.startsWith('$'))
return value
const amount = Number(normalized.slice(1).replaceAll(',', ''))
if (!Number.isFinite(amount))
return value
if (amount < 10_000)
return `$${amount.toFixed(2)}`
return `$${(amount / 1_000).toFixed(3)}k`
}
function trendState(
current: number,
previous: number,
fallbackTone: MetricTone,
): MetricCardView['trend'] {
if (current > previous)
return { direction: 'up', tone: 'success' }
if (current < previous)
return { direction: 'down', tone: 'danger' }
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
}
function trendSummaryTone(label: string) {
if (label.includes('错误'))
return 'danger'
if (label.includes('总耗时'))
return 'warning'
if (label.includes('吞吐') || label.includes('输出') || label.includes('成功'))
return 'success'
if (label.includes('首字') || label.includes('缓存'))
return 'normal'
return 'info'
}
function trendSummaryColorVar(kind: DashboardTrendKind, label: string) {
if (kind === 'latency') {
if (label.includes('总耗时'))
return '--cp-color-orange-solid'
if (label.includes('吞吐'))
return '--cp-color-green-solid'
return '--cp-color-cyan-solid'
}
if (kind === 'errors') {
if (label.includes('错误'))
return '--cp-color-red-solid'
if (label.includes('成功'))
return '--cp-color-green-solid'
return '--cp-color-blue-solid'
}
if (label.includes('输出'))
return '--cp-color-green-solid'
if (label.includes('缓存'))
return '--cp-color-text-tertiary'
return '--cp-color-blue-solid'
}
@@ -1,6 +1,6 @@
import type { BarSeriesOption, EChartsOption, LineSeriesOption } from 'echarts'
import type { Ref } from 'vue'
import type { dashboardTrendView, normalizeDashboardTrendKind } from './useDashboard'
import type { dashboardTrendView, normalizeDashboardTrendKind } from '../presenter'
import { usePreferredReducedMotion } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
+1 -94
View File
@@ -1,5 +1,4 @@
import type { MetricTone } from './composables/useDashboard'
import { formatInteger } from '@/utils/format'
import type { MetricTone } from './presenter'
// tone 到 Tailwind 类的唯一映射;仪表盘各卡片共用。
export const metricToneIconClasses: Record<MetricTone, string> = {
@@ -17,95 +16,3 @@ export const metricToneValueClasses: Record<MetricTone, string> = {
warning: 'text-cp-warning-text',
danger: 'text-cp-error-text',
}
export type HealthStatus
= 'future' | 'no_data' | 'unavailable' | 'unstable' | 'low_sample' | 'stable'
export interface HealthTimelinePoint {
bucketStart: string
time: string
status: HealthStatus
reliabilityDisplay: string
successRequests: number
failedRequests: number
cancelledRequests: number
callerErrorRequests: number
}
export interface HealthTimeline {
title: string
description: string
reliabilityDisplay: string
status: HealthStatus
successRequests: number
failedRequests: number
cancelledRequests: number
callerErrorRequests: number
points: HealthTimelinePoint[]
}
interface HealthStatusMeta {
label: string
cellClass: string
badgeClass: string
}
export const healthLegend = [
{ status: 'no_data', label: '无有效样本' },
{ status: 'unavailable', label: '不可达' },
{ status: 'unstable', label: '不稳定' },
{ status: 'low_sample', label: '低样本' },
{ status: 'stable', label: '稳定' },
] satisfies { status: HealthStatus, label: string }[]
export const healthStatusMeta: Record<HealthStatus, HealthStatusMeta> = {
future: {
label: '未来',
cellClass: 'bg-cp-bg-container-disabled opacity-60',
badgeClass: 'bg-cp-fill-tertiary text-cp-text-quaternary',
},
no_data: {
label: '无有效样本',
cellClass: 'bg-cp-border',
badgeClass: 'bg-cp-fill-tertiary text-cp-text-secondary',
},
unavailable: {
label: '不可达',
cellClass: 'bg-cp-error',
badgeClass: 'bg-cp-error-container text-cp-error-on-container',
},
unstable: {
label: '不稳定',
cellClass: 'bg-cp-warning',
badgeClass: 'bg-cp-warning-container text-cp-warning-on-container',
},
low_sample: {
label: '低样本',
cellClass: 'bg-cp-cyan-solid',
badgeClass: 'bg-cp-cyan-container text-cp-cyan-on-container',
},
stable: {
label: '稳定',
cellClass: 'bg-cp-success',
badgeClass: 'bg-cp-success-container text-cp-success-on-container',
},
}
export function healthReliabilityValueClass(successRequests: number, failedRequests: number) {
const eligibleRequests = Math.max(0, successRequests) + Math.max(0, failedRequests)
if (eligibleRequests === 0)
return 'text-cp-text-quaternary'
const reliability = (Math.max(0, successRequests) / eligibleRequests) * 100
if (reliability >= 99.5)
return 'text-cp-success-text'
if (reliability >= 98)
return 'text-cp-cyan-text'
if (reliability >= 95)
return 'text-cp-warning-text'
return 'text-cp-error-text'
}
export function formatHealthCount(value: number) {
return formatInteger(Math.max(0, value))
}
+370
View File
@@ -0,0 +1,370 @@
import type { Component } from 'vue'
import type { DashboardSummaryResponse, DashboardTrendKind, DashboardTrendPoint, DashboardTrendResponse } from '@/api'
import { Activity, FileText, Timer, Users } from '@lucide/vue'
import { formatCompactNumber, formatInteger } from '@/utils/format'
export type MetricTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
export interface MetricCardView {
title: string
value: string
valueRaw?: number | null
valueFormatter?: (value: number) => string
icon: Component
tone: MetricTone
sparkline?: {
values: number[]
tone: MetricTone
}
trend?: {
direction: 'up' | 'down' | 'flat'
tone: MetricTone
}
details: Array<{
label: string
value: string
tone?: MetricTone
}>
}
const metricSparklineBuckets = 12
const emptyCards: DashboardSummaryResponse['cards'] = {
credentials: {
total: '0',
totalValue: 0,
available: '0',
availableValue: 0,
unavailable: '0',
unavailableValue: 0,
},
traffic: {
todayRequests: '0',
todayRequestsValue: 0,
yesterdayRequestsValue: 0,
totalRequests: '0',
},
tokens: {
todayTokens: '0',
todayTokensValue: 0,
yesterdayTokensValue: 0,
totalTokens: '0',
totalBillingAmountUsd: '—',
},
cache: {
todayHitRate: '—',
todayHitRateValue: null,
yesterdayHitRateValue: null,
totalHitRate: '—',
totalCachedTokens: '0',
averageFirstTokenLatencyMs: '—',
},
}
const emptyHealthTimeline: DashboardSummaryResponse['healthTimeline'] = {
title: '请求健康时间线',
description: '有效请求可用性',
reliabilityDisplay: '-',
status: 'no_data',
successRequests: 0,
failedRequests: 0,
cancelledRequests: 0,
incompleteRequests: 0,
callerErrorRequests: 0,
points: [],
}
const emptyCapacityInfo = {
maxConcurrentPerAccount: null,
totalSlots: null,
usedSlots: null,
availableSlots: null,
}
export function dashboardSnapshotView(summary: DashboardSummaryResponse | null) {
const trendPoints = summary?.trend.points ?? []
return {
metrics: metricCards(summary?.cards ?? emptyCards, trendPoints),
healthTimeline: summary?.healthTimeline ?? emptyHealthTimeline,
accountUsage: (summary?.accountUsage ?? []),
wireProfiles: summary?.wireProfiles ?? [],
usageRecords: summary?.usageRecords ?? [],
poolSummary: summary?.poolSummary ?? null,
capacityInfo: summary?.capacityInfo ?? emptyCapacityInfo,
rotationStrategy: summary?.rotationStrategy ?? null,
}
}
export function dashboardTrendView(trend: DashboardTrendResponse | null) {
if (!trend) {
const points: ReturnType<typeof aggregateUsageTrend> = []
const summary: ReturnType<typeof usageTrendSummary> = []
return { points, summary }
}
if (trend.kind === 'usage') {
const points = aggregateUsageTrend(trend.points)
return { points, summary: usageTrendSummary(points) }
}
const summary = []
for (const item of trend.summary) {
summary.push({
label: item.label,
value: trend.kind === 'errors' && item.ratio !== null ? item.ratio : item.value,
tone: trendSummaryTone(item.label),
colorVar: trendSummaryColorVar(trend.kind, item.label),
})
}
return { points: trend.points, summary }
}
export function normalizeDashboardTrendKind(kind: string): DashboardTrendKind {
if (kind === 'latency' || kind === 'errors')
return kind
return 'usage'
}
function metricCards(
cards: DashboardSummaryResponse['cards'],
points: DashboardTrendPoint[],
): MetricCardView[] {
const { credentials, traffic, tokens, cache } = cards
const recentPoints = recentTrendWindow(points)
return [
{
title: '账号',
value: credentials.total,
valueRaw: credentials.totalValue,
valueFormatter: formatAccountCount,
icon: Users,
tone: 'normal',
details: [
{
label: '可用',
value: credentials.available,
tone: 'success',
},
{
label: '不可用',
value: credentials.unavailable,
tone: 'danger',
},
],
},
{
title: '请求次数',
value: traffic.todayRequests,
valueRaw: traffic.todayRequestsValue,
valueFormatter: formatCompactNumber,
icon: Activity,
tone: 'info',
sparkline: sparkline(
recentPoints.map(point => point.requestsValue),
'info',
),
trend: trendState(traffic.todayRequestsValue, traffic.yesterdayRequestsValue, 'info'),
details: [
{ label: '总请求', value: traffic.totalRequests, tone: 'info' },
{ label: '首字均值', value: cache.averageFirstTokenLatencyMs, tone: 'info' },
],
},
{
title: 'Token',
value: tokens.todayTokens,
valueRaw: tokens.todayTokensValue,
valueFormatter: formatCompactNumber,
icon: FileText,
tone: 'success',
sparkline: sparkline(
recentPoints.map(point => point.tokensValue),
'success',
),
trend: trendState(tokens.todayTokensValue, tokens.yesterdayTokensValue, 'success'),
details: [
{ label: '总 Token', value: tokens.totalTokens, tone: 'success' },
{ label: '总计费', value: formatDashboardUsd(tokens.totalBillingAmountUsd), tone: 'success' },
],
},
{
title: '缓存命中',
value: cache.todayHitRate,
valueRaw: cache.todayHitRateValue,
valueFormatter: formatDashboardRate,
icon: Timer,
tone: cache.todayHitRateValue && cache.todayHitRateValue > 0 ? 'warning' : 'normal',
sparkline: sparkline(
recentPoints.map(point => point.cacheHitRateValue),
'warning',
),
trend: trendState(
cache.todayHitRateValue ?? 0,
cache.yesterdayHitRateValue ?? 0,
'warning',
),
details: [
{ label: '总缓存命中', value: cache.totalHitRate, tone: 'warning' },
{ label: '总缓存', value: cache.totalCachedTokens, tone: 'warning' },
],
},
]
}
function formatAccountCount(value: number) {
return formatInteger(Math.max(0, Math.round(value)))
}
function sparkline(values: number[], tone: MetricTone) {
return values.some(value => value > 0) ? { values, tone } : undefined
}
function recentTrendWindow(points: DashboardTrendPoint[]) {
let lastActiveIndex = points.length - 1
while (lastActiveIndex >= 0 && points[lastActiveIndex].requestsValue <= 0) lastActiveIndex -= 1
if (lastActiveIndex < 0)
return []
return points.slice(
Math.max(0, lastActiveIndex - (metricSparklineBuckets - 1)),
lastActiveIndex + 1,
)
}
function formatDashboardRate(value: number) {
return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '—'
}
function aggregateUsageTrend(points: DashboardTrendPoint[]) {
return Array.from({ length: Math.ceil(points.length / 2) }, (_, groupIndex) => {
const group = points.slice(groupIndex * 2, groupIndex * 2 + 2)
const first = group[0]
const requestsValue = sum(group, point => point.requestsValue)
const errorsValue = sum(group, point => point.errorsValue)
const inputTokensValue = sum(group, point => point.inputTokensValue)
const outputTokensValue = sum(group, point => point.outputTokensValue)
const cachedTokensValue = Math.min(
inputTokensValue,
sum(group, point => point.cachedTokensValue),
)
const uncachedInputTokensValue = inputTokensValue - cachedTokensValue
const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : 0
const successRateValue = requestsValue
? ((requestsValue - errorsValue) / requestsValue) * 100
: 0
return {
...first,
requests: formatCompactNumber(requestsValue),
requestsValue,
inputTokens: formatCompactNumber(inputTokensValue),
inputTokensValue,
outputTokens: formatCompactNumber(outputTokensValue),
outputTokensValue,
cachedTokens: formatCompactNumber(cachedTokensValue),
cachedTokensValue,
uncachedInputTokens: formatCompactNumber(uncachedInputTokensValue),
uncachedInputTokensValue,
effectiveTokens: formatCompactNumber(effectiveTokensValue),
effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null,
cacheHitRate: formatDashboardRate(cacheHitRateValue),
cacheHitRateValue,
tokensValue: inputTokensValue + outputTokensValue,
errors: formatCompactNumber(errorsValue),
errorsValue,
successRate: `${successRateValue.toFixed(1)}%`,
successRateValue,
}
})
}
function sum(points: DashboardTrendPoint[], selector: (point: DashboardTrendPoint) => number) {
return points.reduce((total, point) => total + selector(point), 0)
}
function usageTrendSummary(points: ReturnType<typeof aggregateUsageTrend>) {
const inputTokens = points.reduce((total, point) => total + point.inputTokensValue, 0)
const outputTokens = points.reduce((total, point) => total + point.outputTokensValue, 0)
const cachedTokens = Math.min(
inputTokens,
points.reduce((total, point) => total + point.cachedTokensValue, 0),
)
return [
{
label: '输入',
value: formatCompactNumber(inputTokens),
tone: 'info',
colorVar: '--cp-color-blue-solid',
},
{
label: '输出',
value: formatCompactNumber(outputTokens),
tone: 'success',
colorVar: '--cp-color-green-solid',
},
{
label: '缓存',
value: formatCompactNumber(cachedTokens),
tone: 'normal',
colorVar: '--cp-color-text-tertiary',
},
]
}
function formatDashboardUsd(value: string) {
const normalized = value.trim()
if (!normalized.startsWith('$'))
return value
const amount = Number(normalized.slice(1).replaceAll(',', ''))
if (!Number.isFinite(amount))
return value
if (amount < 10_000)
return `$${amount.toFixed(2)}`
return `$${(amount / 1_000).toFixed(3)}k`
}
function trendState(
current: number,
previous: number,
fallbackTone: MetricTone,
): MetricCardView['trend'] {
if (current > previous)
return { direction: 'up', tone: 'success' }
if (current < previous)
return { direction: 'down', tone: 'danger' }
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
}
function trendSummaryTone(label: string) {
if (label.includes('错误'))
return 'danger'
if (label.includes('总耗时'))
return 'warning'
if (label.includes('吞吐') || label.includes('输出') || label.includes('成功'))
return 'success'
if (label.includes('首字') || label.includes('缓存'))
return 'normal'
return 'info'
}
function trendSummaryColorVar(kind: DashboardTrendKind, label: string) {
if (kind === 'latency') {
if (label.includes('总耗时'))
return '--cp-color-orange-solid'
if (label.includes('吞吐'))
return '--cp-color-green-solid'
return '--cp-color-cyan-solid'
}
if (kind === 'errors') {
if (label.includes('错误'))
return '--cp-color-red-solid'
if (label.includes('成功'))
return '--cp-color-green-solid'
return '--cp-color-blue-solid'
}
if (label.includes('输出'))
return '--cp-color-green-solid'
if (label.includes('缓存'))
return '--cp-color-text-tertiary'
return '--cp-color-blue-solid'
}
@@ -4,7 +4,7 @@ import type { AccountGroup } from '@/api'
import { AlertTriangle, CircleCheck, Grid2X2, Users } from '@lucide/vue'
import { computed } from 'vue'
import { formatUsd } from '@/views/usage/utils/format'
import { formatUsd } from '@/utils/format'
type MetricKind = 'accounts' | 'capacity' | 'usage'
type MetricTone = 'primary' | 'secondary' | 'success' | 'warning' | 'active'
@@ -1,7 +1,6 @@
import type { AccountGroup, ApiKey } from '@/api'
import { normalizeRgbaHexColor, toast } from '@codex-proxy/ui'
import { watchDebounced } from '@vueuse/core'
import { computed, onMounted, ref, shallowRef, watch } from 'vue'
import {
createAccountGroup,
@@ -15,6 +14,7 @@ import {
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useIdSet } from '@/composables/useIdSet'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { useRequestState } from '@/composables/useRequestState'
import { errorMessage } from '@/utils/operation'
import { DEFAULT_ACCOUNT_GROUP_COLOR } from '../constants'
@@ -38,6 +38,7 @@ export function useAccountGroups() {
const pendingDisableGroup = shallowRef<AccountGroup | null>(null)
const deleteCount = shallowRef(0)
const clientKeys = shallowRef<ApiKey[]>([])
const referenceKeysRequest = useRequestState()
const form = ref<AccountGroupFormValue>(emptyForm())
const savingAction = useAsyncAction()
const deletingAction = useAsyncAction()
@@ -56,9 +57,7 @@ export function useAccountGroups() {
}, options),
})
const groups = computed(() => query.items.value.map(group => ({
...group,
})))
const groups = query.items
const pagination = computed(() => ({
currentPage: query.page.value,
pageSize: query.pageSize.value,
@@ -87,18 +86,26 @@ export function useAccountGroups() {
}
async function loadReferenceKeys() {
const requestId = referenceKeysRequest.start()
const requestOptions = { signal: referenceKeysRequest.signal }
try {
const items: ApiKey[] = []
let cursor: string | undefined
do {
const result = await getApiKeys({ limit: 200, cursor })
const result = await getApiKeys({ limit: 200, cursor }, requestOptions)
if (!referenceKeysRequest.isCurrent(requestId))
return
items.push(...result.items)
cursor = result.nextCursor ?? undefined
} while (cursor)
clientKeys.value = items
}
catch {
clientKeys.value = []
if (referenceKeysRequest.isCurrent(requestId))
clientKeys.value = []
}
finally {
referenceKeysRequest.finish(requestId)
}
}
@@ -257,10 +264,13 @@ export function useAccountGroups() {
void query.execute()
}
watchDebounced(searchQuery, () => {
query.page.value = 1
void query.execute()
}, { debounce: 250 })
watch(searchQuery, (_value, _previous, onCleanup) => {
const timer = setTimeout(() => {
query.page.value = 1
void query.execute()
}, 250)
onCleanup(() => clearTimeout(timer))
})
watch(statusQuery, () => {
query.page.value = 1
void query.execute()
@@ -3,11 +3,11 @@ import type { BaseTablePaginationState as Pagination } from '@codex-proxy/ui'
import type { KeyUsageRecord, KeyUsageRecordKind } from '@/api/modules/key-usage'
import { BaseCard, BaseSegmented, BaseTable, BaseTablePagination, defineTableColumns } from '@codex-proxy/ui'
import { computed } from 'vue'
import UsageBillingCell from '@/views/usage/components/UsageBillingCell.vue'
import UsageClientIpCell from '@/views/usage/components/UsageClientIpCell.vue'
import UsageLatencyCell from '@/views/usage/components/UsageLatencyCell.vue'
import UsageTokenCell from '@/views/usage/components/UsageTokenCell.vue'
import UsageTransportBadge from '@/views/usage/components/UsageTransportBadge.vue'
import UsageBillingCell from '@/components/usage/UsageBillingCell.vue'
import UsageClientIpCell from '@/components/usage/UsageClientIpCell.vue'
import UsageLatencyCell from '@/components/usage/UsageLatencyCell.vue'
import UsageTokenCell from '@/components/usage/UsageTokenCell.vue'
import UsageTransportBadge from '@/components/usage/UsageTransportBadge.vue'
defineProps<{ rows: KeyUsageRecord[], pagination: Pagination, loading: boolean, error: string, stale: boolean }>()
defineEmits<{ pageChange: [page: number], pageSizeChange: [size: number] }>()
@@ -3,8 +3,8 @@ import type { KeyUsageMetrics } from '@/api/modules/key-usage'
import { BaseCard, BaseMotionIcon } from '@codex-proxy/ui'
import { Gauge, Zap } from '@lucide/vue'
import { computed } from 'vue'
import AnimatedMetricValue from '@/components/AnimatedMetricValue.vue'
import { formatCompactNumber, formatInteger } from '@/utils/format'
import AnimatedMetricValue from '@/views/dashboard/components/AnimatedMetricValue.vue'
import { money } from '../utils/format'
import { keyUsageTokenMetrics, keyUsageTokenValue } from '../utils/metrics'
@@ -69,14 +69,12 @@ export function useKeyUsage() {
queryGeneration += 1
rangeEnd.value = Date.now()
overview.value = undefined
records.items.value = []
void loadOverview()
void records.reloadFromStart()
}, { immediate: true })
watch(kind, () => {
queryGeneration += 1
records.items.value = []
void records.reloadFromStart()
})

Some files were not shown because too many files have changed in this diff Show More