工具完成
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

This commit is contained in:
2025-06-28 22:38:49 +08:00
parent 2c668fedd0
commit 8400dbfab9
60 changed files with 23197 additions and 144 deletions

View File

@ -0,0 +1,432 @@
<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>