Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

View File

@@ -0,0 +1,505 @@
<template>
<div>
<div v-if="!data || (typeof data === 'object' && Object.keys(data).length === 0)" class="text-sm text-muted-foreground">
{{ emptyMessage }}
</div>
<!-- 纯字符串数据 JSON 对象 -->
<Card v-else-if="typeof data === 'string'" class="bg-muted/30 overflow-hidden">
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
<pre class="text-xs font-mono whitespace-pre-wrap">{{ data }}</pre>
</div>
</Card>
<!-- JSON 响应 HTML 错误页面 -->
<Card v-else-if="data.raw_response && data.metadata?.parse_error" 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>
</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>
</div>
</Card>
<Card v-else class="bg-muted/30 overflow-hidden">
<!-- JSON 查看器 -->
<div class="json-viewer" :class="{ 'theme-dark': isDark }">
<div class="json-lines">
<template v-for="line in visibleLines" :key="line.displayId">
<div class="json-line" :class="{ 'has-fold': line.canFold }">
<!-- 行号区域包含折叠按钮 -->
<div class="line-number-area">
<span
v-if="line.canFold"
class="fold-button"
@click="toggleFold(line.blockId)"
>
<ChevronRight v-if="collapsedBlocks.has(line.blockId)" class="fold-icon" />
<ChevronDown v-else class="fold-icon" />
</span>
<span class="line-number">{{ line.displayLineNumber }}</span>
</div>
<!-- 内容区域 -->
<div class="line-content-area">
<!-- 缩进 -->
<span class="indent" :style="{ width: `${line.indent * 16}px` }"></span>
<!-- 内容 -->
<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)"
></span>
</div>
</div>
</template>
</div>
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ChevronRight, ChevronDown } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
interface JsonLine {
id: number
lineNumber: number
indent: number
html: string
canFold: boolean
blockId: string
blockEnd?: number
collapsedInfo?: string
closingBracket?: string
trailingComma?: string
}
interface DisplayLine extends JsonLine {
displayId: string
displayLineNumber: number
}
const props = defineProps<{
data: any
viewMode: 'formatted' | 'raw' | 'compare'
expandDepth: number
isDark: boolean
emptyMessage: string
}>()
const collapsedBlocks = ref<Set<string>>(new Set())
const lines = ref<JsonLine[]>([])
const getTokenHtml = (value: string, type: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'bracket' | 'punctuation' | 'ellipsis'): string => {
const classMap = {
key: 'token-key',
string: 'token-string',
number: 'token-number',
boolean: 'token-boolean',
null: 'token-null',
bracket: 'token-bracket',
punctuation: 'token-punctuation',
ellipsis: 'token-ellipsis',
}
return `<span class="${classMap[type]}">${escapeHtml(value)}</span>`
}
const escapeHtml = (str: string): string => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
const parseJsonToLines = (data: any): 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 comma = isLast ? '' : ','
if (value === null) {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('null', 'null') + comma,
canFold: false,
blockId: '',
})
} else if (typeof value === 'boolean') {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(String(value), 'boolean') + comma,
canFold: false,
blockId: '',
})
} else if (typeof value === 'number') {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(String(value), 'number') + comma,
canFold: false,
blockId: '',
})
} else if (typeof value === 'string') {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(`"${escapeHtml(value)}"`, 'string') + comma,
canFold: false,
blockId: '',
})
} else if (Array.isArray(value)) {
if (value.length === 0) {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('[]', 'bracket') + comma,
canFold: false,
blockId: '',
})
} else {
const blockId = getBlockId()
const startLine = result.length
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('[', 'bracket'),
canFold: true,
blockId,
collapsedInfo: `${value.length} items`,
closingBracket: ']',
trailingComma: comma,
})
value.forEach((item, i) => {
processValue(item, indent + 1, i === value.length - 1)
})
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: getTokenHtml(']', 'bracket') + comma,
canFold: false,
blockId: '',
})
result[startLine].blockEnd = result.length - 1
}
} else if (typeof value === 'object') {
const keys = Object.keys(value)
if (keys.length === 0) {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('{}', 'bracket') + comma,
canFold: false,
blockId: '',
})
} else {
const blockId = getBlockId()
const startLine = result.length
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('{', 'bracket'),
canFold: true,
blockId,
collapsedInfo: `${keys.length} keys`,
closingBracket: '}',
trailingComma: comma,
})
keys.forEach((key, i) => {
const keyHtml = getTokenHtml(`"${escapeHtml(key)}"`, 'key') + getTokenHtml(': ', 'punctuation')
processValue(value[key], indent + 1, i === keys.length - 1, keyHtml)
})
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: getTokenHtml('}', 'bracket') + comma,
canFold: false,
blockId: '',
})
result[startLine].blockEnd = result.length - 1
}
} else {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(String(value), 'string') + comma,
canFold: false,
blockId: '',
})
}
}
processValue(data, 0, true)
return result
}
const visibleLines = computed((): DisplayLine[] => {
const result: DisplayLine[] = []
const hiddenRanges: Array<{ start: number; end: number }> = []
for (const line of lines.value) {
if (line.canFold && collapsedBlocks.value.has(line.blockId) && line.blockEnd !== undefined) {
hiddenRanges.push({ start: line.id + 1, end: line.blockEnd })
}
}
const isHidden = (id: number): boolean => {
return hiddenRanges.some(range => id >= range.start && id <= range.end)
}
let displayLineNumber = 1
for (const line of lines.value) {
if (!isHidden(line.id)) {
result.push({
...line,
displayId: `display-${line.id}`,
displayLineNumber: displayLineNumber++,
})
}
}
return result
})
const getDisplayHtml = (line: DisplayLine): string => {
if (line.canFold && collapsedBlocks.value.has(line.blockId)) {
const closingBracket = getTokenHtml(line.closingBracket || '}', 'bracket')
const ellipsis = getTokenHtml('...', 'ellipsis')
const comma = line.trailingComma || ''
return `${line.html}${ellipsis}${closingBracket}${comma}<span class="collapsed-info">${line.collapsedInfo}</span>`
}
return line.html
}
const toggleFold = (blockId: string) => {
const newSet = new Set(collapsedBlocks.value)
if (newSet.has(blockId)) {
newSet.delete(blockId)
} else {
newSet.add(blockId)
}
collapsedBlocks.value = newSet
}
const initCollapsedState = () => {
const newSet = new Set<string>()
// 默认展开第一层indent = 0折叠更深层indent >= 1
// expandDepth = 999 表示全部展开
const depth = props.expandDepth === 0 ? 1 : props.expandDepth
if (depth < 999) {
for (const line of lines.value) {
if (line.canFold && line.indent >= depth) {
newSet.add(line.blockId)
}
}
}
collapsedBlocks.value = newSet
}
watch(() => props.data, () => {
if (props.data) {
lines.value = parseJsonToLines(props.data)
initCollapsedState()
} else {
lines.value = []
}
}, { immediate: true })
watch(() => props.expandDepth, () => {
initCollapsedState()
})
</script>
<style scoped>
.json-viewer {
max-height: 500px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 13px;
line-height: 20px;
}
.json-lines {
padding: 4px 0;
}
.json-line {
display: flex;
min-height: 20px;
}
.json-line:hover {
background: hsl(var(--muted) / 0.4);
}
.line-number-area {
flex-shrink: 0;
width: 48px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
background: hsl(var(--muted) / 0.2);
border-right: 1px solid hsl(var(--border));
user-select: none;
}
.fold-button {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: hsl(var(--muted-foreground) / 0.6);
margin-right: 2px;
border-radius: 2px;
}
.fold-button:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted) / 0.8);
}
.fold-icon {
width: 14px;
height: 14px;
}
.line-number {
color: hsl(var(--muted-foreground) / 0.5);
min-width: 20px;
text-align: right;
}
.line-content-area {
flex: 1;
display: flex;
padding-left: 12px;
padding-right: 12px;
}
.indent {
flex-shrink: 0;
}
.line-content {
white-space: pre-wrap;
word-break: break-all;
}
.line-content.clickable-collapsed {
cursor: pointer;
}
.line-content.clickable-collapsed:hover :deep(.token-ellipsis) {
background: hsl(var(--primary) / 0.2);
border-radius: 2px;
}
/* Token 颜色 - 亮色主题 */
:deep(.token-key) {
color: #0451a5;
}
:deep(.token-string) {
color: #a31515;
}
:deep(.token-number) {
color: #098658;
}
:deep(.token-boolean) {
color: #0000ff;
}
:deep(.token-null) {
color: #0000ff;
}
:deep(.token-bracket) {
color: #000000;
}
:deep(.token-punctuation) {
color: #000000;
}
:deep(.token-ellipsis) {
color: #0451a5;
padding: 0 2px;
}
:deep(.collapsed-info) {
color: hsl(var(--muted-foreground));
font-style: italic;
margin-left: 8px;
font-size: 12px;
}
/* Token 颜色 - 暗色主题 */
.theme-dark :deep(.token-key) {
color: #9cdcfe;
}
.theme-dark :deep(.token-string) {
color: #ce9178;
}
.theme-dark :deep(.token-number) {
color: #b5cea8;
}
.theme-dark :deep(.token-boolean) {
color: #569cd6;
}
.theme-dark :deep(.token-null) {
color: #569cd6;
}
.theme-dark :deep(.token-bracket) {
color: #d4d4d4;
}
.theme-dark :deep(.token-punctuation) {
color: #d4d4d4;
}
.theme-dark :deep(.token-ellipsis) {
color: #9cdcfe;
}
.theme-dark .line-number-area {
background: hsl(var(--muted) / 0.3);
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<div>
<!-- 对比模式 - 并排 Diff -->
<div v-show="viewMode === 'compare'">
<div v-if="!detail.request_headers && !detail.provider_request_headers" class="text-sm text-muted-foreground">
无请求头信息
</div>
<Card v-else class="bg-muted/30 overflow-hidden">
<!-- Diff 头部 -->
<div class="flex border-b bg-muted/50">
<div class="flex-1 px-3 py-2 text-xs text-muted-foreground border-r flex items-center justify-between">
<span class="font-medium">客户端请求头</span>
<span class="text-destructive">-{{ headerStats.removed + headerStats.modified }}</span>
</div>
<div class="flex-1 px-3 py-2 text-xs text-muted-foreground flex items-center justify-between">
<span class="font-medium">提供商请求头</span>
<span class="text-green-600 dark:text-green-400">+{{ headerStats.added + headerStats.modified }}</span>
</div>
</div>
<!-- 并排 Diff 内容 -->
<div class="overflow-x-auto max-h-[500px] overflow-y-auto">
<div class="flex font-mono text-xs">
<!-- 左侧客户端 -->
<div class="flex-1 border-r">
<template v-for="entry in sortedEntries" :key="'left-' + entry.key">
<!-- 删除的行 -->
<div v-if="entry.status === 'removed'" class="flex items-start bg-destructive/10 px-3 py-0.5">
<span class="text-destructive">
"{{ entry.key }}": "{{ entry.clientValue }}"
</span>
</div>
<!-- 修改的行 - 旧值 -->
<div v-else-if="entry.status === 'modified'" class="flex items-start bg-amber-500/10 px-3 py-0.5">
<span class="text-amber-600 dark:text-amber-400">
"{{ entry.key }}": "{{ entry.clientValue }}"
</span>
</div>
<!-- 新增的行 - 左侧空白占位 -->
<div v-else-if="entry.status === 'added'" class="flex items-start bg-muted/30 px-3 py-0.5">
<span class="text-muted-foreground/30 italic"></span>
</div>
<!-- 未变化的行 -->
<div v-else class="flex items-start px-3 py-0.5 hover:bg-muted/50">
<span class="text-muted-foreground">
"{{ entry.key }}": "{{ entry.clientValue }}"
</span>
</div>
</template>
</div>
<!-- 右侧提供商 -->
<div class="flex-1">
<template v-for="entry in sortedEntries" :key="'right-' + entry.key">
<!-- 删除的行 - 右侧空白占位 -->
<div v-if="entry.status === 'removed'" class="flex items-start bg-muted/30 px-3 py-0.5">
<span class="text-muted-foreground/50 line-through">
"{{ entry.key }}": "{{ entry.clientValue }}"
</span>
</div>
<!-- 修改的行 - 新值 -->
<div v-else-if="entry.status === 'modified'" class="flex items-start bg-amber-500/10 px-3 py-0.5">
<span class="text-amber-600 dark:text-amber-400">
"{{ entry.key }}": "{{ entry.providerValue }}"
</span>
</div>
<!-- 新增的行 -->
<div v-else-if="entry.status === 'added'" class="flex items-start bg-green-500/10 px-3 py-0.5">
<span class="text-green-600 dark:text-green-400">
"{{ entry.key }}": "{{ entry.providerValue }}"
</span>
</div>
<!-- 未变化的行 -->
<div v-else class="flex items-start px-3 py-0.5 hover:bg-muted/50">
<span class="text-muted-foreground">
"{{ entry.key }}": "{{ entry.providerValue }}"
</span>
</div>
</template>
</div>
</div>
</div>
</Card>
</div>
<!-- 格式化模式 - 直接使用 JsonContent -->
<div v-show="viewMode === 'formatted'">
<JsonContent
:data="currentHeaderData"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无请求头信息"
/>
</div>
<!-- 原始模式 -->
<div v-show="viewMode === 'raw'">
<div v-if="!currentHeaderData || Object.keys(currentHeaderData).length === 0" class="text-sm text-muted-foreground">
无请求头信息
</div>
<Card v-else class="bg-muted/30">
<div class="p-4 overflow-x-auto">
<pre class="text-xs font-mono whitespace-pre-wrap">{{ JSON.stringify(currentHeaderData, null, 2) }}</pre>
</div>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Card from '@/components/ui/card.vue'
import JsonContent from './JsonContent.vue'
import type { RequestDetail } from '@/api/dashboard'
const props = defineProps<{
detail: RequestDetail
viewMode: 'compare' | 'formatted' | 'raw'
dataSource: 'client' | 'provider'
currentHeaderData: any
currentExpandDepth: number
hasProviderHeaders: boolean
clientHeadersWithDiff: Array<{ key: string; value: any; status: string }>
providerHeadersWithDiff: Array<{ key: string; value: any; status: string }>
headerStats: { added: number; modified: number; removed: number; unchanged: number }
isDark: boolean
}>()
// 合并并排序的条目(用于并排显示)
const sortedEntries = computed(() => {
const clientHeaders = props.detail.request_headers || {}
const providerHeaders = props.detail.provider_request_headers || {}
const clientKeys = new Set(Object.keys(clientHeaders))
const providerKeys = new Set(Object.keys(providerHeaders))
const allKeys = Array.from(new Set([...clientKeys, ...providerKeys])).sort()
return allKeys.map(key => {
const inClient = clientKeys.has(key)
const inProvider = providerKeys.has(key)
const clientValue = clientHeaders[key]
const providerValue = providerHeaders[key]
let status: 'added' | 'removed' | 'modified' | 'unchanged'
if (inClient && inProvider) {
status = clientValue === providerValue ? 'unchanged' : 'modified'
} else if (inClient) {
status = 'removed'
} else {
status = 'added'
}
return {
key,
clientValue,
providerValue,
status
}
})
})
</script>