修复前端调试与基础交互问题

This commit is contained in:
MMEXA
2026-07-04 05:24:40 +08:00
parent b86d4e1f0c
commit 242081433e
33 changed files with 525 additions and 320 deletions
+21 -1
View File
@@ -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')
})
})
+32 -9
View File
@@ -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,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',
]"
>
+1 -1
View File
@@ -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"
+7 -5
View File
@@ -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
-4
View File
@@ -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">
+1 -1
View File
@@ -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 })
+153 -46
View File
@@ -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>
+18 -7
View File
@@ -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')