432 lines
14 KiB
Vue
432 lines
14 KiB
Vue
<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> |