Files
utils/src/components/tools/HttpTester.vue
zguiy 8400dbfab9
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
工具完成
2025-06-28 22:38:49 +08:00

432 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2">
<button
@click="sendRequest"
:disabled="!requestUrl.trim() || isLoading"
class="btn-primary flex items-center space-x-2"
>
<FontAwesomeIcon
:icon="isLoading ? ['fas', 'spinner'] : ['fas', 'paper-plane']"
:class="isLoading && 'animate-spin'"
/>
<span>发送请求</span>
</button>
<button
@click="clearAll"
class="btn-secondary flex items-center space-x-2"
>
<FontAwesomeIcon :icon="['fas', 'trash']" />
<span>清空</span>
</button>
</div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<!-- 左侧请求配置 -->
<div class="space-y-6">
<!-- 请求URL和方法 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-4">请求配置</h3>
<div class="space-y-3">
<div class="flex space-x-2">
<select v-model="requestMethod" class="select-input w-32">
<option v-for="method in httpMethods" :key="method" :value="method">
{{ method }}
</option>
</select>
<input
v-model="requestUrl"
type="url"
placeholder="https://api.example.com/users"
class="input-field flex-1"
@keyup.enter="sendRequest"
>
</div>
<!-- 快速URL -->
<div class="flex flex-wrap gap-2">
<button
v-for="quickUrl in quickUrls"
:key="quickUrl.name"
@click="setQuickUrl(quickUrl.url)"
class="px-3 py-1 text-xs rounded bg-block hover:bg-block-hover text-secondary hover:text-primary transition-colors"
>
{{ quickUrl.name }}
</button>
</div>
</div>
</div>
<!-- 请求头配置 -->
<div class="card p-4">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium text-secondary">请求头</h4>
<button @click="addHeader" class="btn-small">
<FontAwesomeIcon :icon="['fas', 'plus']" class="mr-1" />
添加头部
</button>
</div>
<div class="space-y-2">
<div
v-for="(header, index) in requestHeaders"
:key="index"
class="flex space-x-2"
>
<input
v-model="header.key"
type="text"
placeholder="Header Name"
class="input-field flex-1"
>
<input
v-model="header.value"
type="text"
placeholder="Header Value"
class="input-field flex-1"
>
<button
@click="removeHeader(index)"
class="p-2 text-error hover:bg-error/10 rounded transition-colors"
>
<FontAwesomeIcon :icon="['fas', 'times']" />
</button>
</div>
</div>
</div>
<!-- 请求体 -->
<div v-if="['POST', 'PUT', 'PATCH'].includes(requestMethod)" class="card p-4">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium text-secondary">请求体</h4>
<select v-model="requestBodyType" class="select-input w-32">
<option value="json">JSON</option>
<option value="text">Text</option>
<option value="form">Form</option>
</select>
</div>
<textarea
v-model="requestBody"
:placeholder="getBodyPlaceholder()"
class="textarea-field h-40 font-mono text-sm"
/>
<div v-if="requestBodyType === 'json'" class="flex justify-between items-center mt-2">
<button @click="formatJsonBody" class="btn-small">
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-1" />
格式化
</button>
<div class="text-xs text-secondary">
{{ requestBody.length }} 字符
</div>
</div>
</div>
</div>
<!-- 右侧响应结果 -->
<div class="card p-4 min-h-[600px]">
<h3 class="text-lg font-semibold text-primary mb-4">响应结果</h3>
<div v-if="isLoading" class="flex flex-col items-center justify-center h-96">
<FontAwesomeIcon :icon="['fas', 'spinner']" class="text-4xl text-primary animate-spin mb-4" />
<p class="text-secondary">正在发送请求...</p>
</div>
<div v-else-if="lastResponse" class="space-y-4">
<!-- 响应状态栏 -->
<div class="flex items-center justify-between p-3 rounded-lg bg-secondary/10">
<div class="flex items-center space-x-3">
<span :class="[
'px-3 py-1 text-sm font-medium rounded',
getStatusColor(lastResponse.status)
]">
{{ lastResponse.status }} {{ lastResponse.statusText }}
</span>
<span class="text-sm text-secondary">
{{ formatResponseSize(lastResponse.size) }} | {{ lastResponse.time }}ms
</span>
</div>
<button @click="copyResponseContent" class="btn-small">
<FontAwesomeIcon :icon="copied ? ['fas', 'check'] : ['fas', 'copy']" class="mr-1" />
{{ copied ? '已复制' : '复制' }}
</button>
</div>
<!-- 响应内容 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-secondary">响应内容</span>
<button
v-if="isJsonResponse"
@click="toggleJsonFormat"
class="btn-small"
>
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-1" />
{{ jsonFormatted ? '原始' : '格式化' }}
</button>
</div>
<div class="relative">
<pre class="bg-secondary/10 p-4 rounded-lg text-sm font-mono overflow-auto max-h-96 whitespace-pre-wrap">{{ formattedResponseData }}</pre>
</div>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-96 text-tertiary">
<FontAwesomeIcon :icon="['fas', 'paper-plane']" class="text-6xl mb-4 opacity-50" />
<p class="text-lg">暂无响应</p>
<p class="text-sm">点击发送请求按钮开始测试</p>
</div>
</div>
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="card p-4 bg-error/10 border-error/20">
<div class="flex items-center space-x-2 text-error">
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" />
<span>{{ errorMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// 响应式状态
const requestUrl = ref('https://jsonplaceholder.typicode.com/posts/1')
const requestMethod = ref('GET')
const requestHeaders = ref([
{ key: 'Content-Type', value: 'application/json' }
])
const requestBody = ref('')
const requestBodyType = ref('json')
const isLoading = ref(false)
const lastResponse = ref(null)
const errorMessage = ref('')
const copied = ref(false)
const jsonFormatted = ref(true)
// HTTP方法列表
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
// 快速URL列表
const quickUrls = [
{ name: 'JSONPlaceholder', url: 'https://jsonplaceholder.typicode.com/posts/1' },
{ name: 'GitHub API', url: 'https://api.github.com/users/octocat' },
{ name: 'HTTPBin', url: 'https://httpbin.org/get' }
]
// 计算属性
const isJsonResponse = computed(() => {
if (!lastResponse.value) return false
const contentType = lastResponse.value.headers['content-type'] || ''
return contentType.includes('application/json') ||
(typeof lastResponse.value.data === 'object' && lastResponse.value.data !== null)
})
const formattedResponseData = computed(() => {
if (!lastResponse.value) return ''
if (isJsonResponse.value && jsonFormatted.value) {
try {
return JSON.stringify(lastResponse.value.data, null, 2)
} catch {
return String(lastResponse.value.data)
}
}
return typeof lastResponse.value.data === 'string'
? lastResponse.value.data
: JSON.stringify(lastResponse.value.data)
})
// 请求头管理
const addHeader = () => {
requestHeaders.value.push({ key: '', value: '' })
}
const removeHeader = (index: number) => {
requestHeaders.value.splice(index, 1)
}
// 工具函数
const getBodyPlaceholder = () => {
switch (requestBodyType.value) {
case 'json':
return '{\n "key": "value"\n}'
default:
return '请输入请求体内容...'
}
}
const formatJsonBody = () => {
try {
const parsed = JSON.parse(requestBody.value)
requestBody.value = JSON.stringify(parsed, null, 2)
} catch (error) {
console.error('JSON格式化失败:', error)
}
}
const setQuickUrl = (url: string) => {
requestUrl.value = url
}
const getStatusColor = (status: number) => {
if (status >= 200 && status < 300) return 'bg-success/20 text-success'
if (status >= 300 && status < 400) return 'bg-warning/20 text-warning'
if (status >= 400 && status < 500) return 'bg-error/20 text-error'
if (status >= 500) return 'bg-error/30 text-error'
return 'bg-secondary/20 text-secondary'
}
const formatResponseSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
// 发送请求
const sendRequest = async () => {
if (!requestUrl.value.trim()) return
isLoading.value = true
errorMessage.value = ''
lastResponse.value = null
try {
const startTime = performance.now()
// 准备请求头
const headers: Record<string, string> = {}
requestHeaders.value.forEach(h => {
if (h.key.trim() && h.value.trim()) {
headers[h.key] = h.value
}
})
// 准备请求体
let body: string | undefined
if (['POST', 'PUT', 'PATCH'].includes(requestMethod.value)) {
body = requestBody.value
}
// 发送请求
const response = await fetch(requestUrl.value, {
method: requestMethod.value,
headers,
body
})
const endTime = performance.now()
const responseTime = Math.round(endTime - startTime)
// 获取响应头
const responseHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => {
responseHeaders[key] = value
})
// 解析响应体
let responseData: any
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
responseData = await response.json()
} else {
responseData = await response.text()
}
// 计算响应大小
const responseText = typeof responseData === 'string' ? responseData : JSON.stringify(responseData)
const responseSize = new Blob([responseText]).size
lastResponse.value = {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
data: responseData,
time: responseTime,
size: responseSize
}
} catch (error) {
errorMessage.value = `请求失败: ${(error as Error).message}`
} finally {
isLoading.value = false
}
}
// 其他功能
const clearAll = () => {
requestUrl.value = 'https://jsonplaceholder.typicode.com/posts/1'
requestMethod.value = 'GET'
requestHeaders.value = [{ key: 'Content-Type', value: 'application/json' }]
requestBody.value = ''
lastResponse.value = null
errorMessage.value = ''
}
const copyResponseContent = async () => {
if (!lastResponse.value) return
try {
const textToCopy = formattedResponseData.value
await navigator.clipboard.writeText(textToCopy)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
}
}
const toggleJsonFormat = () => {
jsonFormatted.value = !jsonFormatted.value
}
</script>
<style scoped>
.select-input {
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid rgba(var(--color-primary), 0.2);
background-color: rgb(var(--color-bg-card));
color: rgb(var(--color-text-primary));
outline: none;
transition: all 0.2s ease;
}
.select-input:focus {
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 2px rgba(var(--color-primary), 0.2);
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.375rem;
background-color: rgb(var(--color-bg-secondary));
color: rgb(var(--color-primary-light));
border: 1px solid rgb(var(--color-primary));
transition: all 0.2s ease;
cursor: pointer;
}
.btn-small:hover {
background-color: rgba(var(--color-primary), 0.1);
border-color: rgb(var(--color-primary-hover));
}
</style>