mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-08 20:20:19 +08:00
修复前端调试与基础交互问题
This commit is contained in:
@@ -40,6 +40,9 @@ export default [
|
||||
FileReader: 'readonly',
|
||||
HTMLElement: 'readonly',
|
||||
HTMLInputElement: 'readonly',
|
||||
HTMLDivElement: 'readonly',
|
||||
HTMLFormElement: 'readonly',
|
||||
HTMLScriptElement: 'readonly',
|
||||
HTMLSelectElement: 'readonly',
|
||||
HTMLImageElement: 'readonly',
|
||||
HTMLIFrameElement: 'readonly',
|
||||
@@ -145,6 +148,8 @@ export default [
|
||||
SecurityPolicyViolationEvent: 'readonly',
|
||||
DeviceMotionEvent: 'readonly',
|
||||
DeviceOrientationEvent: 'readonly',
|
||||
// Vite build-time constants
|
||||
__APP_VERSION__: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -166,7 +171,12 @@ export default [
|
||||
'vue/no-v-html': 'warn', // 降级为警告,某些场景需要使用
|
||||
'vue/component-api-style': ['error', ['script-setup']],
|
||||
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
|
||||
'vue/custom-event-name-casing': ['warn', 'camelCase'], // 降级为警告,逐步迁移
|
||||
'vue/custom-event-name-casing': ['warn', 'camelCase', {
|
||||
ignores: [
|
||||
'/^update:[A-Za-z][A-Za-z0-9-]*$/',
|
||||
'/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/',
|
||||
],
|
||||
}], // 组件公开事件同时兼容 v-model 与模板 kebab-case 约定
|
||||
'vue/define-macros-order': [
|
||||
'error',
|
||||
{
|
||||
@@ -220,4 +230,14 @@ export default [
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// 测试文件内的本地 stub 组件与断言写法服务于行为隔离,不作为生产组件 API 约束
|
||||
{
|
||||
files: ['**/__tests__/**/*.{ts,tsx,vue}', '**/*.{spec,test}.{ts,tsx,vue}'],
|
||||
rules: {
|
||||
'vue/one-component-per-file': 'off',
|
||||
'vue/require-default-prop': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -24,12 +24,14 @@
|
||||
<template #default>
|
||||
<!-- 描述 -->
|
||||
<div class="space-y-3">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<p
|
||||
v-for="(line, index) in descriptionLines"
|
||||
:key="index"
|
||||
:class="getLineClass(index)"
|
||||
v-html="renderLine(line)"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
|
||||
<!-- 自定义内容插槽 -->
|
||||
|
||||
@@ -85,7 +85,8 @@ const props = withDefaults(defineProps<{
|
||||
presetOptions?: SelectablePreset[]
|
||||
presetTriggerClass?: string
|
||||
}>(), {
|
||||
presetOptions: () => ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom']
|
||||
presetOptions: () => ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'],
|
||||
presetTriggerClass: undefined,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DateRangeParams]
|
||||
|
||||
@@ -290,11 +290,13 @@
|
||||
{{ selectedReleaseHelpText }}
|
||||
</p>
|
||||
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="selectedReleaseDisplayNotes"
|
||||
class="max-h-[26rem] overflow-y-auto rounded-xl border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6 text-foreground/90 shadow-inner shadow-black/[0.02] max-w-none prose prose-sm dark:prose-invert prose-headings:mb-2 prose-headings:mt-4 prose-headings:font-semibold prose-headings:text-foreground prose-h3:text-sm prose-p:my-2 prose-ul:my-2 prose-ul:list-disc prose-ul:pl-5 prose-li:my-1 prose-li:marker:text-primary prose-a:text-primary prose-strong:text-foreground prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5"
|
||||
v-html="selectedReleaseNotesHtml"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<p
|
||||
v-else
|
||||
class="rounded-lg bg-muted/30 px-3 py-4 text-sm text-muted-foreground"
|
||||
|
||||
@@ -100,7 +100,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
const jumpPageInput = ref('')
|
||||
const locale = useI18n().locale
|
||||
|
||||
@@ -20,6 +20,7 @@ const props = withDefaults(defineProps<{
|
||||
filterTitle?: string
|
||||
filterContentClass?: string
|
||||
}>(), {
|
||||
class: undefined,
|
||||
columnKey: undefined,
|
||||
sortable: true,
|
||||
activeKey: null,
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('useSiteInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
apiClientMocks.get.mockReset()
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
it('loads public site info', async () => {
|
||||
@@ -23,10 +24,57 @@ describe('useSiteInfo', () => {
|
||||
})
|
||||
|
||||
const { useSiteInfo } = await import('../useSiteInfo')
|
||||
const { siteName, siteSubtitle, refreshSiteInfo } = useSiteInfo()
|
||||
const { siteName, siteSubtitle, siteInfoLoaded, refreshSiteInfo } = useSiteInfo()
|
||||
await refreshSiteInfo()
|
||||
|
||||
expect(siteName.value).toBe('Custom Aether')
|
||||
expect(siteSubtitle.value).toBe('Gateway')
|
||||
expect(siteInfoLoaded.value).toBe(true)
|
||||
expect(document.title).toBe('Custom Aether')
|
||||
})
|
||||
|
||||
it('keeps default site text hidden until public site info resolves', async () => {
|
||||
let resolveRequest: (value: { data: { site_name: string; site_subtitle: string } }) => void = () => {}
|
||||
apiClientMocks.get.mockReturnValue(new Promise((resolve) => {
|
||||
resolveRequest = resolve
|
||||
}))
|
||||
|
||||
const { useSiteInfo } = await import('../useSiteInfo')
|
||||
const { siteName, siteSubtitle, siteInfoLoaded } = useSiteInfo()
|
||||
|
||||
expect(siteName.value).toBe('')
|
||||
expect(siteSubtitle.value).toBe('')
|
||||
expect(siteInfoLoaded.value).toBe(false)
|
||||
expect(document.title).toBe('')
|
||||
|
||||
resolveRequest({
|
||||
data: {
|
||||
site_name: 'Configured Aether',
|
||||
site_subtitle: 'Configured Gateway',
|
||||
},
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(siteName.value).toBe('Configured Aether')
|
||||
expect(siteSubtitle.value).toBe('Configured Gateway')
|
||||
expect(siteInfoLoaded.value).toBe(true)
|
||||
expect(document.title).toBe('Configured Aether')
|
||||
})
|
||||
|
||||
it('uses upstream defaults only after public site info fails', async () => {
|
||||
apiClientMocks.get.mockRejectedValue(new Error('network unavailable'))
|
||||
|
||||
const { useSiteInfo } = await import('../useSiteInfo')
|
||||
const { siteName, siteSubtitle, siteInfoLoaded, refreshSiteInfo } = useSiteInfo()
|
||||
|
||||
expect(siteName.value).toBe('')
|
||||
expect(siteSubtitle.value).toBe('')
|
||||
|
||||
await refreshSiteInfo()
|
||||
|
||||
expect(siteName.value).toBe('Aether')
|
||||
expect(siteSubtitle.value).toBe('AI Gateway')
|
||||
expect(siteInfoLoaded.value).toBe(true)
|
||||
expect(document.title).toBe('Aether')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { readonly, ref, watch } from 'vue'
|
||||
import apiClient from '@/api/client'
|
||||
|
||||
interface SiteInfo {
|
||||
@@ -6,21 +6,42 @@ interface SiteInfo {
|
||||
site_subtitle: string
|
||||
}
|
||||
|
||||
const DEFAULT_SITE_INFO: SiteInfo = {
|
||||
site_name: 'Aether',
|
||||
site_subtitle: 'AI Gateway',
|
||||
}
|
||||
|
||||
// 模块级缓存,所有组件共享同一份数据
|
||||
const siteName = ref('Aether')
|
||||
const siteSubtitle = ref('AI Gateway')
|
||||
const siteName = ref('')
|
||||
const siteSubtitle = ref('')
|
||||
const loaded = ref(false)
|
||||
let fetchPromise: Promise<void> | null = null
|
||||
|
||||
function normalizeSiteInfo(data: Partial<SiteInfo> | null | undefined): SiteInfo {
|
||||
return {
|
||||
site_name: data?.site_name?.trim() || DEFAULT_SITE_INFO.site_name,
|
||||
site_subtitle: data?.site_subtitle?.trim() || DEFAULT_SITE_INFO.site_subtitle,
|
||||
}
|
||||
}
|
||||
|
||||
function applySiteInfo(data: Partial<SiteInfo> | null | undefined): void {
|
||||
const normalized = normalizeSiteInfo(data)
|
||||
siteName.value = normalized.site_name
|
||||
siteSubtitle.value = normalized.site_subtitle
|
||||
}
|
||||
|
||||
async function fetchSiteInfo() {
|
||||
try {
|
||||
const response = await apiClient.get<SiteInfo>('/api/public/site-info')
|
||||
siteName.value = response.data.site_name
|
||||
siteSubtitle.value = response.data.site_subtitle
|
||||
loaded.value = true
|
||||
applySiteInfo(response.data)
|
||||
} catch {
|
||||
// 加载失败时保持默认值,允许后续重试
|
||||
// 加载失败时才使用 upstream 默认站点信息,避免配置加载前闪出默认品牌文案
|
||||
if (!siteName.value || !siteSubtitle.value) {
|
||||
applySiteInfo(DEFAULT_SITE_INFO)
|
||||
}
|
||||
fetchPromise = null
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +56,12 @@ export function useSiteInfo() {
|
||||
if (!loaded.value && !fetchPromise) {
|
||||
fetchPromise = fetchSiteInfo()
|
||||
}
|
||||
return { siteName, siteSubtitle, refreshSiteInfo }
|
||||
return { siteName, siteSubtitle, siteInfoLoaded: readonly(loaded), refreshSiteInfo }
|
||||
}
|
||||
|
||||
// 站点名称变化时同步更新 document.title
|
||||
watch(siteName, (name) => {
|
||||
document.title = name
|
||||
if (name) {
|
||||
document.title = name
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
import { createApp, defineComponent, nextTick, type App } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import LoginDialog from '../LoginDialog.vue'
|
||||
|
||||
@@ -917,11 +917,6 @@ async function handleSubmit() {
|
||||
}
|
||||
form.value.supported_capabilities = caps.size > 0 ? [...caps] : []
|
||||
|
||||
// 清理空的 config
|
||||
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
|
||||
? form.value.config
|
||||
: undefined
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEditMode.value && props.model) {
|
||||
|
||||
@@ -216,8 +216,8 @@
|
||||
|
||||
<Button
|
||||
v-for="action in desktopPostProxyActions"
|
||||
:key="action.key"
|
||||
v-show="hasSelectedProvider"
|
||||
:key="action.key"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
|
||||
@@ -133,7 +133,10 @@ export function buildPoolProxyDistributionPlan(
|
||||
}
|
||||
|
||||
const assignments = nodeIds.map((nodeId) => {
|
||||
const assignment = mutableAssignments.get(nodeId)!
|
||||
const assignment = mutableAssignments.get(nodeId)
|
||||
if (!assignment) {
|
||||
throw new Error(`Missing proxy distribution assignment for node ${nodeId}`)
|
||||
}
|
||||
const nodeKeys = [...assignment.retainedKeys, ...assignment.assignedKeys]
|
||||
const changedKeys = nodeKeys.filter(key => getKeyProxyNodeId(key) !== nodeId)
|
||||
return {
|
||||
|
||||
@@ -54,8 +54,12 @@
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium">关联健康加载失败</p>
|
||||
<p class="mt-1 text-xs">{{ errorMessage }}</p>
|
||||
<p class="font-medium">
|
||||
关联健康加载失败
|
||||
</p>
|
||||
<p class="mt-1 text-xs">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -56,6 +56,11 @@ const props = withDefaults(defineProps<{
|
||||
entityLabel?: string
|
||||
entityName?: string | null
|
||||
}>(), {
|
||||
timeline: null,
|
||||
timelineDetails: null,
|
||||
timeRangeStart: null,
|
||||
timeRangeEnd: null,
|
||||
generatedAt: null,
|
||||
lookbackHours: 6,
|
||||
fallbackSegments: 60,
|
||||
entityLabel: '',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<Card
|
||||
variant="default"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<HealthMonitorHeader
|
||||
v-model:lookback-hours="lookbackHours"
|
||||
:title="title"
|
||||
@@ -23,10 +26,15 @@
|
||||
>
|
||||
<Bot class="w-12 h-12 mb-3 opacity-30" />
|
||||
<p>暂无模型健康监控数据</p>
|
||||
<p class="text-xs mt-1">模型尚未产生请求记录</p>
|
||||
<p class="text-xs mt-1">
|
||||
模型尚未产生请求记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"
|
||||
>
|
||||
<div
|
||||
v-for="monitor in monitors"
|
||||
:key="monitor.model"
|
||||
@@ -46,7 +54,10 @@
|
||||
{{ monitor.display_name || monitor.model }}
|
||||
</h4>
|
||||
</div>
|
||||
<Badge :variant="getHealthBadgeVariant(monitor)" class="shrink-0">
|
||||
<Badge
|
||||
:variant="getHealthBadgeVariant(monitor)"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ getHealthLabel(monitor) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -597,9 +597,9 @@
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
:disabled="importing"
|
||||
@click="setWindsurfImportMethod(method.key)"
|
||||
>
|
||||
{{ legacyT(method.label) }}
|
||||
</button>
|
||||
>
|
||||
{{ legacyT(method.label) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -135,8 +135,6 @@ import { formatBillingType } from '@/utils/format'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
isBalanceLoading: (providerId: string) => boolean
|
||||
@@ -150,4 +148,6 @@ defineProps<{
|
||||
formatResetCountdown: (resetsAt: number) => string
|
||||
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
|
||||
}>()
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
</script>
|
||||
|
||||
@@ -948,7 +948,6 @@ import {
|
||||
canExportOAuthCredential,
|
||||
canRefreshOAuthCredential,
|
||||
isOAuthManagedCredential,
|
||||
isServiceAccountCredential,
|
||||
getProviderMaskedSecretLabel,
|
||||
shouldShowOAuthRefreshControl,
|
||||
} from '@/utils/providerKeyAuth'
|
||||
@@ -1302,14 +1301,6 @@ async function toggleFormatConversion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 级别代理配置
|
||||
function handleProviderProxyPopoverToggle(open: boolean) {
|
||||
providerProxyPopoverOpen.value = open
|
||||
if (open) {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderProxyNodeName(): string {
|
||||
const nodeId = provider.value?.proxy?.node_id
|
||||
if (!nodeId) return legacyT('未知节点')
|
||||
|
||||
@@ -124,7 +124,7 @@ import {
|
||||
summarizeHealthMonitorItems
|
||||
} from './health-monitor-utils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
withDefaults(defineProps<{
|
||||
title?: string
|
||||
}>(), {
|
||||
title: '提供商健康监控'
|
||||
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
||||
|
||||
@@ -1496,7 +1496,7 @@ const formatConversionPair = (source: string, target: string): string =>
|
||||
`${formatApiFormat(source.trim())} → ${formatApiFormat(target.trim())}`
|
||||
|
||||
const extractFieldDetail = (message: string): string => {
|
||||
const fieldMatch = message.match(/field\s+([^;=]+?)\s*=\s*(\"(?:\\.|[^"\\])*\"|[^;]+)/i)
|
||||
const fieldMatch = message.match(/field\s+([^;=]+?)\s*=\s*("(?:\\.|[^"\\])*"|[^;]+)/i)
|
||||
const unsupportedFieldMatch = message.match(/field\s+([^;]+?)\s+is unsupported/i)
|
||||
if (fieldMatch?.[1]) {
|
||||
return `字段 ${normalizeDiagnosticFieldPath(fieldMatch[1])} = ${fieldMatch[2].trim()}`
|
||||
|
||||
@@ -876,7 +876,7 @@ import Skeleton from '@/components/ui/skeleton.vue'
|
||||
import Tabs from '@/components/ui/tabs.vue'
|
||||
import TabsContent from '@/components/ui/tabs-content.vue'
|
||||
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
|
||||
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
|
||||
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import {
|
||||
@@ -889,7 +889,6 @@ import { log } from '@/utils/logger'
|
||||
import { getEffectiveInputTokens } from '../token-normalization'
|
||||
import {
|
||||
formatDurationMs,
|
||||
formatOutputRate,
|
||||
formatOutputRateValue,
|
||||
getDisplayOutputRate,
|
||||
} from '../performance'
|
||||
@@ -1006,15 +1005,6 @@ type JsonRecord = Record<string, unknown>
|
||||
|
||||
const METADATA_BYTE_FIELD_PATTERN = /(^bytes$|_bytes$|bytes$)/i
|
||||
|
||||
type NormalizedErrorDomain = {
|
||||
source?: string | null
|
||||
status_code?: number | null
|
||||
type?: string | null
|
||||
message: string
|
||||
code?: string | number | null
|
||||
category?: string | null
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
return value as JsonRecord
|
||||
@@ -1043,28 +1033,6 @@ function formatMetadataDisplayValue(value: unknown, key = ''): unknown {
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): NormalizedErrorDomain | null {
|
||||
if (!domain || typeof domain !== 'object') return null
|
||||
const message = typeof domain.message === 'string' ? domain.message.trim() : ''
|
||||
if (!message) return null
|
||||
return {
|
||||
source: domain.source ?? null,
|
||||
status_code: domain.status_code ?? null,
|
||||
type: domain.type ?? null,
|
||||
message,
|
||||
code: domain.code ?? null,
|
||||
category: domain.category ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorDomainMeta(domain: NormalizedErrorDomain): string {
|
||||
const parts: string[] = []
|
||||
if (domain.status_code != null) parts.push(`HTTP ${domain.status_code}`)
|
||||
if (domain.type) parts.push(domain.type)
|
||||
if (domain.source) parts.push(`source=${domain.source}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function mapTraceFinalStatusToRequestStatus(
|
||||
status?: RequestTrace['final_status'] | null
|
||||
): RequestStateStatus | undefined {
|
||||
|
||||
@@ -674,16 +674,18 @@ export class OpenAIParser implements ApiFormatParser {
|
||||
: typeof chunk.delta === 'string'
|
||||
? chunk.delta
|
||||
: null
|
||||
if (key && toolCalls.has(key) && args != null) {
|
||||
toolCalls.get(key)!.args = [args]
|
||||
const toolCall = key ? toolCalls.get(key) : undefined
|
||||
if (toolCall && args != null) {
|
||||
toolCall.args = [args]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (eventType === 'response.custom_tool_call_input.done') {
|
||||
const key = resolveToolKey(chunk)
|
||||
if (key && toolCalls.has(key) && typeof chunk.input === 'string') {
|
||||
toolCalls.get(key)!.args = [chunk.input]
|
||||
const toolCall = key ? toolCalls.get(key) : undefined
|
||||
if (toolCall && typeof chunk.input === 'string') {
|
||||
toolCall.args = [chunk.input]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
</Badge>
|
||||
<span
|
||||
v-else
|
||||
class="font-semibold tabular-nums"
|
||||
:class="[
|
||||
mobile ? 'text-base leading-none' : 'text-sm',
|
||||
'font-semibold tabular-nums',
|
||||
row.isNegativeBalance ? 'text-rose-600' : 'text-foreground',
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ const MARKDOWN_STRUCTURE_PATTERNS = [
|
||||
const SENTENCE_END_PUNCTUATION = /[,。,.!?!?;;]$/
|
||||
const SECTION_HEADING_SUFFIX = /[::]\s*$/
|
||||
const URL_PATTERN = /https?:\/\/|www\./i
|
||||
const BRACKET_PREFIX_PATTERN = /^[\[((【<]/
|
||||
const BRACKET_PREFIX_PATTERN = /^[[(【<]/
|
||||
const SECTION_HEADING_TEXT_PATTERN = /[\u3400-\u9FFFA-Za-z]/
|
||||
|
||||
function isStructuredMarkdownLine(line: string): boolean {
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
<MetricCell
|
||||
label="进程 CPU"
|
||||
:value="formatBasisPointsPercent(gatewayMetrics?.process.processCpuUsageBasisPoints)"
|
||||
:value-class="resourceToneClass(gatewayProcessCpuPercent, 70, 90)"
|
||||
/>
|
||||
<MetricCell
|
||||
label="RSS"
|
||||
|
||||
@@ -2016,12 +2016,14 @@ function refreshOverviewInBackground(): void {
|
||||
}
|
||||
|
||||
function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof refreshProviderQuota>>): void {
|
||||
const successfulResults = Array.isArray(result.results)
|
||||
? result.results.filter((item) => item.status === 'success' && item.quota_snapshot)
|
||||
: []
|
||||
if (successfulResults.length === 0) return
|
||||
const quotaByKeyId = new Map<string, NonNullable<NonNullable<typeof result.results>[number]['quota_snapshot']>>()
|
||||
for (const item of result.results) {
|
||||
if (item.status === 'success' && item.quota_snapshot) {
|
||||
quotaByKeyId.set(item.key_id, item.quota_snapshot)
|
||||
}
|
||||
}
|
||||
if (quotaByKeyId.size === 0) return
|
||||
|
||||
const quotaByKeyId = new Map(successfulResults.map((item) => [item.key_id, item.quota_snapshot!]))
|
||||
keyPage.value.keys = keyPage.value.keys.map((key) => {
|
||||
const quotaSnapshot = quotaByKeyId.get(key.key_id)
|
||||
if (!quotaSnapshot) return key
|
||||
|
||||
@@ -650,10 +650,6 @@ function updateNodeDetailState(nodeId: string, patch: Partial<ProxyNodeDetailSta
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeExpanded(nodeId: string) {
|
||||
return expandedNodeIds.value.has(nodeId)
|
||||
}
|
||||
|
||||
function toggleNodeDetails(node: ProxyNode) {
|
||||
const next = new Set(expandedNodeIds.value)
|
||||
if (next.has(node.id)) {
|
||||
|
||||
@@ -26,11 +26,21 @@
|
||||
<Select v-model="logLevel">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="trace">trace</SelectItem>
|
||||
<SelectItem value="debug">debug</SelectItem>
|
||||
<SelectItem value="info">info</SelectItem>
|
||||
<SelectItem value="warn">warn</SelectItem>
|
||||
<SelectItem value="error">error</SelectItem>
|
||||
<SelectItem value="trace">
|
||||
trace
|
||||
</SelectItem>
|
||||
<SelectItem value="debug">
|
||||
debug
|
||||
</SelectItem>
|
||||
<SelectItem value="info">
|
||||
info
|
||||
</SelectItem>
|
||||
<SelectItem value="warn">
|
||||
warn
|
||||
</SelectItem>
|
||||
<SelectItem value="error">
|
||||
error
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -49,9 +59,15 @@
|
||||
<Select v-model="schedulingState">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">active</SelectItem>
|
||||
<SelectItem value="draining">draining</SelectItem>
|
||||
<SelectItem value="cordoned">cordoned</SelectItem>
|
||||
<SelectItem value="active">
|
||||
active
|
||||
</SelectItem>
|
||||
<SelectItem value="draining">
|
||||
draining
|
||||
</SelectItem>
|
||||
<SelectItem value="cordoned">
|
||||
cordoned
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,172 +1,170 @@
|
||||
<template>
|
||||
<template>
|
||||
<TableRow
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
:class="expanded ? 'bg-muted/20' : ''"
|
||||
>
|
||||
<TableCell class="w-[28px] min-w-[28px] max-w-[28px] p-0 pl-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
:title="expanded ? legacyT('收起数据') : legacyT('展开数据')"
|
||||
@click="$emit('toggle-details')"
|
||||
>
|
||||
<ChevronDown
|
||||
v-if="expanded"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
<ChevronRight
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
class="min-w-0 truncate text-sm font-semibold"
|
||||
:title="node.name"
|
||||
>{{ node.name }}</span>
|
||||
<Badge
|
||||
v-if="node.is_manual"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ legacyT('手动') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="node.tunnel_mode"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
Tunnel
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="schedulingBadge"
|
||||
:variant="schedulingBadge.variant"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ legacyT(schedulingBadge.label) }}
|
||||
</Badge>
|
||||
<HardwareTooltip :node="node" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 min-w-0">
|
||||
<code
|
||||
class="block min-w-0 truncate text-xs text-muted-foreground"
|
||||
:title="proxyNodeAddress(node)"
|
||||
>{{ proxyNodeAddress(node) }}</code>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 min-w-0">
|
||||
<span
|
||||
class="block min-w-0 truncate text-sm text-muted-foreground"
|
||||
:title="legacyT(formatProxyNodeRegion(node.region))"
|
||||
>{{ legacyT(formatProxyNodeRegion(node.region)) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge
|
||||
:variant="proxyNodeStatusVariant(node.status)"
|
||||
:title="legacyT(proxyNodeStatusTitle(node))"
|
||||
class="font-medium px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
{{ legacyT(proxyNodeStatusLabel(node)) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ formatProxyNodeNumber(node.total_requests) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span
|
||||
class="text-sm tabular-nums"
|
||||
:class="proxyNodeFailureRate(node) > 5 ? 'text-destructive font-medium' : ''"
|
||||
>{{ formatProxyNodeFailureRate(node) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ node.is_manual ? '-' : proxyNodeVersion(node) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 min-w-0">
|
||||
<span class="block min-w-0 truncate text-xs text-muted-foreground">{{ formatProxyNodeTime(node.last_heartbeat_at, locale) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="testing ? legacyT('测试中...') : legacyT('测试连通性')"
|
||||
:disabled="testing"
|
||||
@click="$emit('test')"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testing"
|
||||
class="h-4 w-4 animate-spin"
|
||||
/>
|
||||
<Activity
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('编辑')"
|
||||
@click="$emit('edit')"
|
||||
>
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('远程配置')"
|
||||
@click="$emit('config')"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('连接事件')"
|
||||
@click="$emit('view-events')"
|
||||
>
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('删除')"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-if="expanded"
|
||||
class="border-b border-border/40 hover:bg-transparent"
|
||||
>
|
||||
<TableCell
|
||||
colspan="11"
|
||||
class="p-0"
|
||||
<TableRow
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
:class="expanded ? 'bg-muted/20' : ''"
|
||||
>
|
||||
<TableCell class="w-[28px] min-w-[28px] max-w-[28px] p-0 pl-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
:title="expanded ? legacyT('收起数据') : legacyT('展开数据')"
|
||||
@click="$emit('toggle-details')"
|
||||
>
|
||||
<ProxyNodeDataPanel
|
||||
:node="node"
|
||||
:state="detailState"
|
||||
@refresh="$emit('refresh-details')"
|
||||
<ChevronDown
|
||||
v-if="expanded"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<ChevronRight
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
class="min-w-0 truncate text-sm font-semibold"
|
||||
:title="node.name"
|
||||
>{{ node.name }}</span>
|
||||
<Badge
|
||||
v-if="node.is_manual"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ legacyT('手动') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="node.tunnel_mode"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
Tunnel
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="schedulingBadge"
|
||||
:variant="schedulingBadge.variant"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ legacyT(schedulingBadge.label) }}
|
||||
</Badge>
|
||||
<HardwareTooltip :node="node" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 min-w-0">
|
||||
<code
|
||||
class="block min-w-0 truncate text-xs text-muted-foreground"
|
||||
:title="proxyNodeAddress(node)"
|
||||
>{{ proxyNodeAddress(node) }}</code>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 min-w-0">
|
||||
<span
|
||||
class="block min-w-0 truncate text-sm text-muted-foreground"
|
||||
:title="legacyT(formatProxyNodeRegion(node.region))"
|
||||
>{{ legacyT(formatProxyNodeRegion(node.region)) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge
|
||||
:variant="proxyNodeStatusVariant(node.status)"
|
||||
:title="legacyT(proxyNodeStatusTitle(node))"
|
||||
class="font-medium px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
{{ legacyT(proxyNodeStatusLabel(node)) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ formatProxyNodeNumber(node.total_requests) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span
|
||||
class="text-sm tabular-nums"
|
||||
:class="proxyNodeFailureRate(node) > 5 ? 'text-destructive font-medium' : ''"
|
||||
>{{ formatProxyNodeFailureRate(node) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm tabular-nums">{{ node.is_manual ? '-' : proxyNodeVersion(node) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 min-w-0">
|
||||
<span class="block min-w-0 truncate text-xs text-muted-foreground">{{ formatProxyNodeTime(node.last_heartbeat_at, locale) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="testing ? legacyT('测试中...') : legacyT('测试连通性')"
|
||||
:disabled="testing"
|
||||
@click="$emit('test')"
|
||||
>
|
||||
<Loader2
|
||||
v-if="testing"
|
||||
class="h-4 w-4 animate-spin"
|
||||
/>
|
||||
<Activity
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('编辑')"
|
||||
@click="$emit('edit')"
|
||||
>
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('远程配置')"
|
||||
@click="$emit('config')"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('连接事件')"
|
||||
@click="$emit('view-events')"
|
||||
>
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="legacyT('删除')"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-if="expanded"
|
||||
class="border-b border-border/40 hover:bg-transparent"
|
||||
>
|
||||
<TableCell
|
||||
colspan="11"
|
||||
class="p-0"
|
||||
>
|
||||
<ProxyNodeDataPanel
|
||||
:node="node"
|
||||
:state="detailState"
|
||||
@refresh="$emit('refresh-details')"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -128,11 +128,11 @@ import { useI18n } from '@/i18n'
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentPosition: 'left'
|
||||
})
|
||||
const { t } = useI18n()
|
||||
defineEmits<{
|
||||
copy: [text: string]
|
||||
'update:platformValue': [value: string]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
// Expose section element for parent scroll tracking
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
defineExpose({ sectionEl: sectionRef })
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<!-- 页面头部:统计卡片 + 公告 -->
|
||||
<div class="flex flex-col lg:flex-row gap-6 lg:items-start">
|
||||
<!-- 左侧统计区域 -->
|
||||
<div ref="statsPanelRef" class="flex-1 min-w-0 flex flex-col">
|
||||
<div
|
||||
ref="statsPanelRef"
|
||||
class="flex-1 min-w-0 flex flex-col"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<Badge
|
||||
:variant="authStore.isAdmin ? 'default' : 'secondary'"
|
||||
@@ -60,7 +63,10 @@
|
||||
class="absolute top-3 right-3 sm:top-5 sm:right-5 rounded-xl sm:rounded-2xl border border-border bg-card/50 p-2 sm:p-3 shadow-inner backdrop-blur-sm"
|
||||
:class="getStatIconColor(index)"
|
||||
>
|
||||
<component :is="stat.icon" class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<component
|
||||
:is="stat.icon"
|
||||
class="h-4 w-4 sm:h-5 sm:w-5"
|
||||
/>
|
||||
</div>
|
||||
<!-- 内容区域 -->
|
||||
<div>
|
||||
@@ -145,9 +151,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 管理员:系统健康摘要 -->
|
||||
<div v-if="isAdmin && systemHealth" class="mt-6">
|
||||
<div
|
||||
v-if="isAdmin && systemHealth"
|
||||
class="mt-6"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-foreground">本月系统健康</h3>
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
本月系统健康
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="uppercase tracking-[0.3em] text-[10px]"
|
||||
@@ -246,12 +257,14 @@
|
||||
<div
|
||||
v-else-if="
|
||||
!isAdmin &&
|
||||
(hasCacheData || (userMonthlyCost !== null && userMonthlyCost > 0))
|
||||
(hasCacheData || (userMonthlyCost !== null && userMonthlyCost > 0))
|
||||
"
|
||||
class="mt-6"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-foreground">本月统计</h3>
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
本月统计
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="uppercase tracking-[0.3em] text-[10px]"
|
||||
@@ -280,7 +293,10 @@
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card v-if="cacheStats" class="relative p-3 sm:p-4 border-kraft/30">
|
||||
<Card
|
||||
v-if="cacheStats"
|
||||
class="relative p-3 sm:p-4 border-kraft/30"
|
||||
>
|
||||
<Hash
|
||||
class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground"
|
||||
/>
|
||||
@@ -348,7 +364,9 @@
|
||||
:style="announcementsContainerStyle"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between flex-shrink-0">
|
||||
<h3 class="text-sm font-medium text-foreground">系统公告</h3>
|
||||
<h3 class="text-sm font-medium text-foreground">
|
||||
系统公告
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="uppercase tracking-[0.3em] text-[10px]"
|
||||
@@ -372,14 +390,19 @@
|
||||
class="flex-1 flex flex-col items-center justify-center"
|
||||
>
|
||||
<Bell class="h-8 w-8 text-muted-foreground/40" />
|
||||
<p class="mt-2 text-xs text-muted-foreground">暂无公告</p>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
暂无公告
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="-mx-4 px-4 flex-1 overflow-y-auto scrollbar-thin min-h-0 pb-2"
|
||||
>
|
||||
<div ref="announcementsTimelineRef" class="relative pl-5">
|
||||
<div
|
||||
ref="announcementsTimelineRef"
|
||||
class="relative pl-5"
|
||||
>
|
||||
<div
|
||||
v-if="announcements.length > 1"
|
||||
class="absolute left-[7px] w-[2px] bg-slate-200 dark:bg-muted"
|
||||
@@ -459,13 +482,19 @@
|
||||
>
|
||||
统计周期
|
||||
</h3>
|
||||
<TimeRangePicker v-model="dailyTimeRange" :allow-hourly="true" />
|
||||
<TimeRangePicker
|
||||
v-model="dailyTimeRange"
|
||||
:allow-hourly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 趋势图表区域 -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- 每日使用趋势(折线图)- 普通用户可见 -->
|
||||
<Card v-if="!isAdmin" class="p-5">
|
||||
<Card
|
||||
v-if="!isAdmin"
|
||||
class="p-5"
|
||||
>
|
||||
<h4
|
||||
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
||||
>
|
||||
@@ -477,11 +506,14 @@
|
||||
>
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
<div v-else style="height: 280px">
|
||||
<div
|
||||
v-else
|
||||
style="height: 280px"
|
||||
>
|
||||
<LineChart
|
||||
v-if="
|
||||
dailyUsageTrendChartData.labels &&
|
||||
dailyUsageTrendChartData.labels.length > 0
|
||||
dailyUsageTrendChartData.labels.length > 0
|
||||
"
|
||||
:data="dailyUsageTrendChartData"
|
||||
:options="dailyUsageTrendChartOptions"
|
||||
@@ -496,7 +528,10 @@
|
||||
</Card>
|
||||
|
||||
<!-- 每日模型成本(堆叠柱状图)- 仅管理员可见 -->
|
||||
<Card v-if="isAdmin" class="p-5">
|
||||
<Card
|
||||
v-if="isAdmin"
|
||||
class="p-5"
|
||||
>
|
||||
<h4
|
||||
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
||||
>
|
||||
@@ -508,11 +543,14 @@
|
||||
>
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
<div v-else style="height: 280px">
|
||||
<div
|
||||
v-else
|
||||
style="height: 280px"
|
||||
>
|
||||
<BarChart
|
||||
v-if="
|
||||
dailyModelCostChartData.labels &&
|
||||
dailyModelCostChartData.labels.length > 0
|
||||
dailyModelCostChartData.labels.length > 0
|
||||
"
|
||||
:data="dailyModelCostChartData"
|
||||
:options="dailyModelCostChartOptions"
|
||||
@@ -527,7 +565,10 @@
|
||||
</Card>
|
||||
|
||||
<!-- 提供商成本分布(环形图)- 仅管理员可见 -->
|
||||
<Card v-if="isAdmin" class="p-5">
|
||||
<Card
|
||||
v-if="isAdmin"
|
||||
class="p-5"
|
||||
>
|
||||
<h4
|
||||
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
||||
>
|
||||
@@ -539,11 +580,14 @@
|
||||
>
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
<div v-else style="height: 280px">
|
||||
<div
|
||||
v-else
|
||||
style="height: 280px"
|
||||
>
|
||||
<DoughnutChart
|
||||
v-if="
|
||||
providerCostChartData.labels &&
|
||||
providerCostChartData.labels.length > 0
|
||||
providerCostChartData.labels.length > 0
|
||||
"
|
||||
:data="providerCostChartData"
|
||||
:options="providerCostChartOptions"
|
||||
@@ -558,7 +602,10 @@
|
||||
</Card>
|
||||
|
||||
<!-- 每日模型成本(堆叠柱状图)- 普通用户可见 -->
|
||||
<Card v-if="!isAdmin" class="p-5">
|
||||
<Card
|
||||
v-if="!isAdmin"
|
||||
class="p-5"
|
||||
>
|
||||
<h4
|
||||
class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider"
|
||||
>
|
||||
@@ -570,11 +617,14 @@
|
||||
>
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
<div v-else style="height: 280px">
|
||||
<div
|
||||
v-else
|
||||
style="height: 280px"
|
||||
>
|
||||
<BarChart
|
||||
v-if="
|
||||
dailyModelCostChartData.labels &&
|
||||
dailyModelCostChartData.labels.length > 0
|
||||
dailyModelCostChartData.labels.length > 0
|
||||
"
|
||||
:data="dailyModelCostChartData"
|
||||
:options="dailyModelCostChartOptions"
|
||||
@@ -594,9 +644,14 @@
|
||||
<!-- 移动端:卡片列表 -->
|
||||
<div class="sm:hidden">
|
||||
<div class="px-4 py-3 border-b border-border/60">
|
||||
<h3 class="text-sm font-semibold">每日统计</h3>
|
||||
<h3 class="text-sm font-semibold">
|
||||
每日统计
|
||||
</h3>
|
||||
</div>
|
||||
<div v-if="loadingDaily" class="flex items-center justify-center py-8">
|
||||
<div
|
||||
v-if="loadingDaily"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<Skeleton class="h-5 w-5 rounded-full" />
|
||||
<span class="ml-2 text-muted-foreground text-xs">加载中...</span>
|
||||
</div>
|
||||
@@ -606,7 +661,10 @@
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
<div v-else class="divide-y divide-border/60">
|
||||
<div
|
||||
v-else
|
||||
class="divide-y divide-border/60"
|
||||
>
|
||||
<div
|
||||
v-for="stat in dailyStats.slice().reverse()"
|
||||
:key="stat.date"
|
||||
@@ -616,7 +674,10 @@
|
||||
<span class="font-medium text-sm">{{
|
||||
formatDate(stat.date)
|
||||
}}</span>
|
||||
<Badge variant="success" class="text-[10px]">
|
||||
<Badge
|
||||
variant="success"
|
||||
class="text-[10px]"
|
||||
>
|
||||
${{ stat.cost.toFixed(4) }}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -646,20 +707,38 @@
|
||||
<Table class="hidden sm:table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="text-left"> 日期 </TableHead>
|
||||
<TableHead class="text-center"> 请求次数 </TableHead>
|
||||
<TableHead class="text-center"> Tokens </TableHead>
|
||||
<TableHead class="text-center"> 费用 </TableHead>
|
||||
<TableHead class="text-center"> 平均响应 </TableHead>
|
||||
<TableHead class="text-center"> 使用模型 </TableHead>
|
||||
<TableHead v-if="isAdmin" class="text-center">
|
||||
<TableHead class="text-left">
|
||||
日期
|
||||
</TableHead>
|
||||
<TableHead class="text-center">
|
||||
请求次数
|
||||
</TableHead>
|
||||
<TableHead class="text-center">
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead class="text-center">
|
||||
费用
|
||||
</TableHead>
|
||||
<TableHead class="text-center">
|
||||
平均响应
|
||||
</TableHead>
|
||||
<TableHead class="text-center">
|
||||
使用模型
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-if="isAdmin"
|
||||
class="text-center"
|
||||
>
|
||||
使用提供商
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loadingDaily">
|
||||
<TableCell :colspan="isAdmin ? 7 : 6" class="text-center py-8">
|
||||
<TableCell
|
||||
:colspan="isAdmin ? 7 : 6"
|
||||
class="text-center py-8"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Skeleton class="h-5 w-5 rounded-full" />
|
||||
<span class="text-muted-foreground text-xs">加载中...</span>
|
||||
@@ -686,24 +765,36 @@
|
||||
{{ stat.requests.toLocaleString() }}
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="secondary" class="text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-[10px]"
|
||||
>
|
||||
{{ formatTokens(stat.tokens) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="success" class="text-[10px]">
|
||||
<Badge
|
||||
variant="success"
|
||||
class="text-[10px]"
|
||||
>
|
||||
${{ stat.cost.toFixed(4) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="outline" class="text-[10px]">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px]"
|
||||
>
|
||||
{{ formatResponseTime(stat.avg_response_time) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-xs">
|
||||
{{ stat.unique_models }}
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="text-center text-xs">
|
||||
<TableCell
|
||||
v-if="isAdmin"
|
||||
class="text-center text-xs"
|
||||
>
|
||||
{{ stat.unique_providers }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -718,25 +809,33 @@
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">总请求</div>
|
||||
<div class="text-muted-foreground text-[10px]">
|
||||
总请求
|
||||
</div>
|
||||
<div class="font-semibold text-foreground">
|
||||
{{ totalStats.requests.toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">总Tokens</div>
|
||||
<div class="text-muted-foreground text-[10px]">
|
||||
总Tokens
|
||||
</div>
|
||||
<div class="font-semibold text-book-cloth dark:text-kraft">
|
||||
{{ formatTokens(totalStats.tokens) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">总费用</div>
|
||||
<div class="text-muted-foreground text-[10px]">
|
||||
总费用
|
||||
</div>
|
||||
<div class="font-semibold text-amber-600 dark:text-amber-400">
|
||||
${{ totalStats.cost.toFixed(4) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">平均响应</div>
|
||||
<div class="text-muted-foreground text-[10px]">
|
||||
平均响应
|
||||
</div>
|
||||
<div class="font-semibold text-book-cloth dark:text-kraft">
|
||||
{{ formatResponseTime(totalStats.avgResponseTime) }}
|
||||
</div>
|
||||
@@ -747,7 +846,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 公告详情对话框 -->
|
||||
<Dialog v-model="detailDialogOpen" size="lg">
|
||||
<Dialog
|
||||
v-model="detailDialogOpen"
|
||||
size="lg"
|
||||
>
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -763,13 +865,18 @@
|
||||
>
|
||||
{{ selectedAnnouncement?.title || "公告详情" }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">系统公告</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
系统公告
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="selectedAnnouncement" class="space-y-4">
|
||||
<div
|
||||
v-if="selectedAnnouncement"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ formatFullDate(selectedAnnouncement.created_at) }}
|
||||
</div>
|
||||
|
||||
@@ -102,16 +102,28 @@
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="rounded-lg border border-border/40 bg-card/50 px-3 py-2">
|
||||
<p class="text-muted-foreground">总数</p>
|
||||
<p class="mt-1 font-semibold tabular-nums">{{ section.summary.total }}</p>
|
||||
<p class="text-muted-foreground">
|
||||
总数
|
||||
</p>
|
||||
<p class="mt-1 font-semibold tabular-nums">
|
||||
{{ section.summary.total }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/40 bg-card/50 px-3 py-2">
|
||||
<p class="text-muted-foreground">异常</p>
|
||||
<p class="mt-1 font-semibold tabular-nums text-red-600 dark:text-red-400">{{ section.summary.unhealthy }}</p>
|
||||
<p class="text-muted-foreground">
|
||||
异常
|
||||
</p>
|
||||
<p class="mt-1 font-semibold tabular-nums text-red-600 dark:text-red-400">
|
||||
{{ section.summary.unhealthy }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/40 bg-card/50 px-3 py-2">
|
||||
<p class="text-muted-foreground">波动</p>
|
||||
<p class="mt-1 font-semibold tabular-nums text-amber-600 dark:text-amber-400">{{ section.summary.warning }}</p>
|
||||
<p class="text-muted-foreground">
|
||||
波动
|
||||
</p>
|
||||
<p class="mt-1 font-semibold tabular-nums text-amber-600 dark:text-amber-400">
|
||||
{{ section.summary.warning }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -217,7 +229,6 @@ const combinedSummary = computed(() => {
|
||||
})
|
||||
|
||||
const overallLabel = computed(() => getStatusLabel(combinedSummary.value, allSectionsLoaded.value))
|
||||
const overallBadgeVariant = computed(() => getStatusBadgeVariant(combinedSummary.value, allSectionsLoaded.value))
|
||||
|
||||
const overviewCards = computed(() => {
|
||||
const endpointSummary = getSummary('endpoint')
|
||||
|
||||
Reference in New Issue
Block a user