mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
+1
-1
@@ -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
-1
@@ -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<{
|
||||
+4
-4
@@ -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<{
|
||||
+2
-2
@@ -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
|
||||
@@ -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
|
||||
|
||||
+4
-3
@@ -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) => {
|
||||
+3
-3
@@ -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])
|
||||
+10
-10
@@ -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}`
|
||||
}
|
||||
+5
-5
@@ -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
-1
@@ -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 }
|
||||
+4
-4
@@ -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))
|
||||
+4
-3
@@ -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))
|
||||
+5
-5
@@ -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 子代理请求"
|
||||
+15
-16
@@ -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="压缩请求"
|
||||
+5
-5
@@ -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
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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
-1
@@ -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
-1
@@ -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'
|
||||
+6
-6
@@ -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 })
|
||||
|
||||
+2
-17
@@ -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
|
||||
+5
-5
@@ -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
|
||||
}>()
|
||||
|
||||
+2
-2
@@ -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
|
||||
+3
-2
@@ -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({
|
||||
+2
-2
@@ -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')
|
||||
+3
-3
@@ -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
|
||||
+2
-2
@@ -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[]
|
||||
+7
-6
@@ -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]
|
||||
}>()
|
||||
+4
-4
@@ -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<{
|
||||
+2
-2
@@ -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
|
||||
+4
-4
@@ -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)
|
||||
+3
-2
@@ -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]
|
||||
|
||||
/**
|
||||
* 根据账号最近一次模型请求选择对应额度组。
|
||||
+2
-2
@@ -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,
|
||||
+5
-9
@@ -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
-1
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+20
-5
@@ -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,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))
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user