mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
refactor: 前端全面替换 any 为 unknown 并统一错误处理,后端用量记录补写请求头/体
- 前端 API 层、stores、conversation 解析器、组件全面替换 any 为 unknown/具体类型 - 错误处理统一使用 parseApiError/getErrorStatus 替代 err.response?.data?.detail 模式 - 后端 handler/TaskService/UsageLifecycle/StreamTracker 链路传递 request_headers/request_body - streaming/pending 状态更新时可补写客户端和提供商的请求头及请求体 - 新增 TaskService 和 UsageService 相关测试
This commit is contained in:
@@ -371,6 +371,7 @@ import Skeleton from '@/components/ui/skeleton.vue'
|
||||
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
|
||||
import { requestTraceApi, type RequestTrace, type CandidateRecord } from '@/api/requestTrace'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
|
||||
// 节点组类型
|
||||
@@ -459,8 +460,10 @@ const getFinalStatusLabel = (status: string) => {
|
||||
}
|
||||
|
||||
// 获取最终状态徽章样式
|
||||
const getFinalStatusBadgeVariant = (status: string): any => {
|
||||
const variants: Record<string, string> = {
|
||||
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
|
||||
|
||||
const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
|
||||
const variants: Record<string, BadgeVariant> = {
|
||||
success: 'success',
|
||||
failed: 'destructive',
|
||||
streaming: 'secondary',
|
||||
@@ -493,19 +496,19 @@ const formatSize = (bytes: number): string => {
|
||||
}
|
||||
|
||||
// 代理 timing 分阶段展示
|
||||
const proxyTimingBreakdown = (proxy: Record<string, any>): string => {
|
||||
const t = proxy.timing
|
||||
const proxyTimingBreakdown = (proxy: Record<string, unknown>): string => {
|
||||
const t = proxy.timing as Record<string, number | null | undefined> | undefined
|
||||
if (!t) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// 兼容旧版 timing(含 body_read_ms/decompress_ms)
|
||||
const readDecompress = (t.body_read_ms || 0) + (t.decompress_ms || 0)
|
||||
const readDecompress = ((t.body_read_ms as number) || 0) + ((t.decompress_ms as number) || 0)
|
||||
if (readDecompress > 0) {
|
||||
let label = `读取 ${formatLatency(readDecompress)}`
|
||||
if (t.decompress_ms != null && t.decompress_ms > 0 && t.wire_size != null && t.body_size != null && t.body_size > 0) {
|
||||
const ratio = Math.round((1 - t.wire_size / t.body_size) * 100)
|
||||
label += ` ${formatSize(t.wire_size)}→${formatSize(t.body_size)}`
|
||||
if (t.decompress_ms != null && t.decompress_ms > 0 && t.wire_size != null && t.body_size != null && (t.body_size as number) > 0) {
|
||||
const ratio = Math.round((1 - (t.wire_size as number) / (t.body_size as number)) * 100)
|
||||
label += ` ${formatSize(t.wire_size as number)}→${formatSize(t.body_size as number)}`
|
||||
if (ratio > 0) label += ` -${ratio}%`
|
||||
}
|
||||
parts.push(label)
|
||||
@@ -514,29 +517,29 @@ const proxyTimingBreakdown = (proxy: Record<string, any>): string => {
|
||||
const ttfbMs = t.ttfb_ms ?? t.upstream_ms
|
||||
const processingMs = t.upstream_processing_ms ?? (
|
||||
ttfbMs != null && t.connect_ms != null && t.tls_ms != null
|
||||
? Math.max(0, ttfbMs - t.connect_ms - t.tls_ms)
|
||||
? Math.max(0, (ttfbMs as number) - (t.connect_ms as number) - (t.tls_ms as number))
|
||||
: null
|
||||
)
|
||||
|
||||
if (t.dns_ms != null && t.dns_ms > 0) {
|
||||
parts.push(`DNS ${formatLatency(t.dns_ms)}`)
|
||||
if (t.dns_ms != null && (t.dns_ms as number) > 0) {
|
||||
parts.push(`DNS ${formatLatency(t.dns_ms as number)}`)
|
||||
}
|
||||
if (t.connect_ms != null && t.connect_ms > 0) {
|
||||
parts.push(`连接 ${formatLatency(t.connect_ms)}`)
|
||||
if (t.connect_ms != null && (t.connect_ms as number) > 0) {
|
||||
parts.push(`连接 ${formatLatency(t.connect_ms as number)}`)
|
||||
}
|
||||
if (t.tls_ms != null && t.tls_ms > 0) {
|
||||
parts.push(`TLS ${formatLatency(t.tls_ms)}`)
|
||||
if (t.tls_ms != null && (t.tls_ms as number) > 0) {
|
||||
parts.push(`TLS ${formatLatency(t.tls_ms as number)}`)
|
||||
}
|
||||
if (ttfbMs != null && ttfbMs > 0) {
|
||||
parts.push(`TTFB ${formatLatency(ttfbMs)}`)
|
||||
if (ttfbMs != null && (ttfbMs as number) > 0) {
|
||||
parts.push(`TTFB ${formatLatency(ttfbMs as number)}`)
|
||||
}
|
||||
if (processingMs != null && processingMs > 0) {
|
||||
parts.push(`上游处理 ${formatLatency(Math.round(processingMs))}`)
|
||||
if (processingMs != null && (processingMs as number) > 0) {
|
||||
parts.push(`上游处理 ${formatLatency(Math.round(processingMs as number))}`)
|
||||
}
|
||||
|
||||
// 计算 Aether→代理 之间无法解释的耗时差
|
||||
if (proxy.ttfb_ms != null && t.total_ms != null) {
|
||||
const gap = proxy.ttfb_ms - t.total_ms
|
||||
const gap = (proxy.ttfb_ms as number) - (t.total_ms as number)
|
||||
if (gap > 500) {
|
||||
parts.push(`传输 ${formatLatency(Math.round(gap))}`)
|
||||
}
|
||||
@@ -817,9 +820,9 @@ const loadTrace = async (silent = false) => {
|
||||
|
||||
try {
|
||||
trace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
if (!silent) {
|
||||
error.value = err.response?.data?.detail || err.message || '加载失败'
|
||||
error.value = parseApiError(err, '加载失败')
|
||||
}
|
||||
log.error('加载请求追踪失败:', err)
|
||||
} finally {
|
||||
|
||||
@@ -699,6 +699,7 @@ const autoRefreshing = ref(false)
|
||||
const curlCopying = ref(false)
|
||||
const curlCopied = ref(false)
|
||||
const replayDialogOpen = ref(false)
|
||||
const AUTO_REFRESH_INTERVAL_MS = 1000
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, (newTab) => {
|
||||
@@ -1010,15 +1011,15 @@ const visibleTabs = computed(() => {
|
||||
return tabs.filter(tab => {
|
||||
switch (tab.name) {
|
||||
case 'request-headers':
|
||||
return hasContent(detail.value!.request_headers)
|
||||
return hasContent(detail.value?.request_headers) || hasContent(detail.value?.provider_request_headers)
|
||||
case 'request-body':
|
||||
return hasContent(detail.value!.request_body) || hasContent(detail.value!.provider_request_body)
|
||||
return hasContent(detail.value?.request_body) || hasContent(detail.value?.provider_request_body)
|
||||
case 'response-headers':
|
||||
return hasContent(detail.value!.response_headers) || hasContent(detail.value!.client_response_headers)
|
||||
return hasContent(detail.value?.response_headers) || hasContent(detail.value?.client_response_headers)
|
||||
case 'response-body':
|
||||
return hasContent(detail.value!.response_body) || hasContent(detail.value!.client_response_body)
|
||||
return hasContent(detail.value?.response_body) || hasContent(detail.value?.client_response_body)
|
||||
case 'metadata':
|
||||
return hasContent(detail.value!.metadata)
|
||||
return hasContent(detail.value?.metadata)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1078,6 +1079,15 @@ async function loadDetail(id: string, silent = false) {
|
||||
if (silent) {
|
||||
timelineRef.value?.refresh()
|
||||
}
|
||||
|
||||
// 抽屉打开时,对进行中请求自动保持刷新,保证详情实时更新
|
||||
if (props.isOpen) {
|
||||
if (isRequestCompleted()) {
|
||||
stopAutoRefresh()
|
||||
} else {
|
||||
startAutoRefresh()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to load request detail:', err)
|
||||
if (!silent) {
|
||||
@@ -1108,6 +1118,23 @@ function stopAutoRefresh() {
|
||||
autoRefreshing.value = false
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshTimer.value || !props.requestId || !props.isOpen) {
|
||||
return
|
||||
}
|
||||
autoRefreshing.value = true
|
||||
autoRefreshTimer.value = setInterval(async () => {
|
||||
if (!props.requestId || !props.isOpen) {
|
||||
stopAutoRefresh()
|
||||
return
|
||||
}
|
||||
await loadDetail(props.requestId, true)
|
||||
if (isRequestCompleted()) {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}, AUTO_REFRESH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function refreshDetail() {
|
||||
if (!props.requestId) return
|
||||
|
||||
@@ -1132,16 +1159,7 @@ async function refreshDetail() {
|
||||
return
|
||||
}
|
||||
|
||||
autoRefreshTimer.value = setInterval(async () => {
|
||||
if (!props.requestId || !props.isOpen) {
|
||||
stopAutoRefresh()
|
||||
return
|
||||
}
|
||||
await loadDetail(props.requestId, true)
|
||||
if (isRequestCompleted()) {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}, 1000)
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -1300,7 +1318,7 @@ function copyContent(tabName: string) {
|
||||
}
|
||||
} else {
|
||||
// JSON 视图模式:复制原始 JSON
|
||||
let data: any = null
|
||||
let data: unknown = null
|
||||
switch (tabName) {
|
||||
case 'request-headers':
|
||||
data = dataSource.value === 'provider'
|
||||
@@ -1380,8 +1398,8 @@ function openReplayDialog() {
|
||||
interface HeaderEntry {
|
||||
key: string
|
||||
status: 'added' | 'modified' | 'removed' | 'unchanged'
|
||||
originalValue?: any
|
||||
newValue?: any
|
||||
originalValue?: unknown
|
||||
newValue?: unknown
|
||||
}
|
||||
|
||||
const mergedHeaderEntries = computed(() => {
|
||||
|
||||
@@ -17,17 +17,17 @@
|
||||
</Card>
|
||||
<!-- 非 JSON 响应(如 HTML 错误页面) -->
|
||||
<Card
|
||||
v-else-if="data.raw_response && data.metadata?.parse_error"
|
||||
v-else-if="hasParseError"
|
||||
class="bg-muted/30 overflow-hidden"
|
||||
>
|
||||
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-amber-600 dark:text-amber-400 text-sm font-medium">Warning: 响应解析失败</span>
|
||||
<span class="text-xs text-amber-700 dark:text-amber-300">{{ data.metadata.parse_error }}</span>
|
||||
<span class="text-xs text-amber-700 dark:text-amber-300">{{ parseErrorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap text-muted-foreground">{{ data.raw_response }}</pre>
|
||||
<pre class="text-xs font-mono whitespace-pre-wrap text-muted-foreground">{{ rawResponseContent }}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
@@ -74,12 +74,14 @@
|
||||
:style="{ width: `${line.indent * 16}px` }"
|
||||
/>
|
||||
<!-- 内容 -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<span
|
||||
class="line-content"
|
||||
:class="{ 'clickable-collapsed': line.canFold && collapsedBlocks.has(line.blockId) }"
|
||||
@click="line.canFold && collapsedBlocks.has(line.blockId) && toggleFold(line.blockId)"
|
||||
v-html="getDisplayHtml(line)"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -112,14 +114,47 @@ interface DisplayLine extends JsonLine {
|
||||
displayLineNumber: number
|
||||
}
|
||||
|
||||
/** JSON data can be any serializable value: object, array, string, number, boolean, null */
|
||||
type JsonValue = Record<string, unknown> | unknown[] | string | number | boolean | null | undefined
|
||||
|
||||
const props = defineProps<{
|
||||
data: any
|
||||
data: JsonValue
|
||||
viewMode: 'formatted' | 'raw' | 'compare'
|
||||
expandDepth: number
|
||||
isDark: boolean
|
||||
emptyMessage: string
|
||||
}>()
|
||||
|
||||
/** Safely cast data to an object for property access in templates */
|
||||
const dataAsObject = computed(() => {
|
||||
if (props.data && typeof props.data === 'object' && !Array.isArray(props.data)) {
|
||||
return props.data as Record<string, unknown>
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
/** Whether the data contains a raw_response with a parse error (non-JSON response) */
|
||||
const hasParseError = computed(() => {
|
||||
const obj = dataAsObject.value
|
||||
if (!obj) return false
|
||||
const metadata = obj.metadata as Record<string, unknown> | undefined
|
||||
return Boolean(obj.raw_response && metadata?.parse_error)
|
||||
})
|
||||
|
||||
/** Parse error message */
|
||||
const parseErrorMessage = computed(() => {
|
||||
const obj = dataAsObject.value
|
||||
if (!obj) return ''
|
||||
const metadata = obj.metadata as Record<string, unknown> | undefined
|
||||
return String(metadata?.parse_error || '')
|
||||
})
|
||||
|
||||
/** Raw response content */
|
||||
const rawResponseContent = computed(() => {
|
||||
const obj = dataAsObject.value
|
||||
return obj ? String(obj.raw_response || '') : ''
|
||||
})
|
||||
|
||||
const collapsedBlocks = ref<Set<string>>(new Set())
|
||||
const lines = ref<JsonLine[]>([])
|
||||
|
||||
@@ -145,14 +180,14 @@ const escapeHtml = (str: string): string => {
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
const parseJsonToLines = (data: any): JsonLine[] => {
|
||||
const parseJsonToLines = (data: unknown): JsonLine[] => {
|
||||
const result: JsonLine[] = []
|
||||
let lineNumber = 1
|
||||
let blockIdCounter = 0
|
||||
|
||||
const getBlockId = () => `block-${blockIdCounter++}`
|
||||
|
||||
const processValue = (value: any, indent: number, isLast: boolean, keyPrefix: string = ''): void => {
|
||||
const processValue = (value: unknown, indent: number, isLast: boolean, keyPrefix: string = ''): void => {
|
||||
const comma = isLast ? '' : ','
|
||||
|
||||
if (value === null) {
|
||||
@@ -232,7 +267,8 @@ const parseJsonToLines = (data: any): JsonLine[] => {
|
||||
result[startLine].blockEnd = result.length - 1
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
const keys = Object.keys(value)
|
||||
const obj = value as Record<string, unknown>
|
||||
const keys = Object.keys(obj)
|
||||
if (keys.length === 0) {
|
||||
result.push({
|
||||
id: result.length,
|
||||
@@ -259,7 +295,7 @@ const parseJsonToLines = (data: any): JsonLine[] => {
|
||||
|
||||
keys.forEach((key, i) => {
|
||||
const keyHtml = getTokenHtml(`"${escapeHtml(key)}"`, 'key') + getTokenHtml(': ', 'punctuation')
|
||||
processValue(value[key], indent + 1, i === keys.length - 1, keyHtml)
|
||||
processValue(obj[key], indent + 1, i === keys.length - 1, keyHtml)
|
||||
})
|
||||
|
||||
result.push({
|
||||
|
||||
+3
-3
@@ -165,11 +165,11 @@ const props = defineProps<{
|
||||
detail: RequestDetail
|
||||
viewMode: 'compare' | 'formatted' | 'raw'
|
||||
dataSource: 'client' | 'provider'
|
||||
currentHeaderData: any
|
||||
currentHeaderData: Record<string, unknown> | null
|
||||
currentExpandDepth: number
|
||||
hasProviderHeaders: boolean
|
||||
clientHeadersWithDiff: Array<{ key: string; value: any; status: string }>
|
||||
providerHeadersWithDiff: Array<{ key: string; value: any; status: string }>
|
||||
clientHeadersWithDiff: Array<{ key: string; value: unknown; status: string }>
|
||||
providerHeadersWithDiff: Array<{ key: string; value: unknown; status: string }>
|
||||
headerStats: { added: number; modified: number; removed: number; unchanged: number }
|
||||
isDark: boolean
|
||||
}>()
|
||||
|
||||
Reference in New Issue
Block a user