| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>HTTP 接口调试工作室 | HTTP Request Studio</title> |
| |
| <script src="/static/js/vue.js"></script> |
| <script src="/static/js/tailwind.js"></script> |
| <link href="/static/css/prism.css" rel="stylesheet"> |
| <script src="/static/js/prism.js"></script> |
| <script src="/static/js/prism-json.js"></script> |
| <style> |
| body { font-family: 'Inter', system-ui, -apple-system, sans-serif; } |
| .scrollbar-hide::-webkit-scrollbar { display: none; } |
| .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; } |
| |
| |
| ::-webkit-scrollbar { width: 8px; height: 8px; } |
| ::-webkit-scrollbar-track { background: #1e293b; } |
| ::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; } |
| ::-webkit-scrollbar-thumb:hover { background: #64748b; } |
| |
| .code-editor { |
| font-family: 'Fira Code', monospace; |
| tab-size: 2; |
| } |
| |
| |
| .glass { |
| background: rgba(30, 41, 59, 0.7); |
| backdrop-filter: blur(10px); |
| -webkit-backdrop-filter: blur(10px); |
| } |
| </style> |
| </head> |
| <body class="bg-slate-900 text-slate-200 h-screen overflow-hidden flex flex-col"> |
|
|
| <div id="app" class="w-full h-full flex flex-col"> |
| |
| <header class="h-16 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 border-b border-slate-700 flex items-center px-6 justify-between shrink-0 shadow-lg z-10"> |
| <div class="flex items-center space-x-4"> |
| <div class="bg-gradient-to-br from-blue-500 to-indigo-600 w-10 h-10 rounded-lg shadow-lg flex items-center justify-center transform transition hover:scale-105"> |
| |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 text-white"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /> |
| </svg> |
| </div> |
| <div> |
| <h1 class="font-bold text-xl text-white tracking-wide bg-clip-text text-transparent bg-gradient-to-r from-white to-slate-400">HTTP Request Studio</h1> |
| <p class="text-[10px] text-slate-500 uppercase tracking-widest font-semibold">Pro Edition</p> |
| </div> |
| </div> |
| <div class="flex items-center space-x-4 text-sm"> |
| |
| <div class="relative group"> |
| <button class="text-slate-400 hover:text-white transition flex items-center bg-slate-800/50 hover:bg-slate-700 px-3 py-1.5 rounded-full border border-slate-700/50"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-1.5"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 2.625v-8.25m0 0V3.75m0 3.75a6.01 6.01 0 01-1.5.189m1.5-.189a6.01 6.01 0 011.5.189m-6 2.25h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z" /> |
| </svg> |
| 加载示例 |
| </button> |
| <div class="absolute right-0 mt-2 w-48 bg-slate-800 border border-slate-700 rounded-lg shadow-xl hidden group-hover:block z-50 overflow-hidden"> |
| <a href="#" @click.prevent="loadDemo('get')" class="block px-4 py-2 hover:bg-slate-700 text-slate-300 text-xs">GET JSON Request</a> |
| <a href="#" @click.prevent="loadDemo('post')" class="block px-4 py-2 hover:bg-slate-700 text-slate-300 text-xs">POST JSON Data</a> |
| <a href="#" @click.prevent="loadDemo('image')" class="block px-4 py-2 hover:bg-slate-700 text-slate-300 text-xs">GET Image Binary</a> |
| <a href="#" @click.prevent="loadDemo('404')" class="block px-4 py-2 hover:bg-slate-700 text-slate-300 text-xs">404 Error Test</a> |
| </div> |
| </div> |
|
|
| <div class="h-4 w-px bg-slate-700"></div> |
|
|
| |
| <button @click="showImportModal = true" class="text-slate-400 hover:text-white transition flex items-center px-2"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-1.5"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> |
| </svg> |
| 导入 cURL |
| </button> |
|
|
| <div class="h-4 w-px bg-slate-700"></div> |
|
|
| <span v-if="loading" class="text-blue-400 flex items-center animate-pulse"> |
| <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| 发送中... |
| </span> |
| |
| <a href="https://huggingface.co/spaces/duqing26/http-request-studio" target="_blank" class="hover:text-white transition opacity-70 hover:opacity-100"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /> |
| </svg> |
| </a> |
| </div> |
| </header> |
|
|
| |
| <div class="flex-1 flex overflow-hidden"> |
| |
| <div class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0 transition-all duration-300" :class="{'w-0 opacity-0': !showSidebar}"> |
| <div class="p-3 border-b border-slate-800 flex justify-between items-center bg-slate-800/20"> |
| <span class="font-semibold text-slate-400 text-xs uppercase tracking-wider">History</span> |
| <button @click="clearHistory" class="text-xs text-slate-500 hover:text-red-400 p-1 rounded hover:bg-slate-800 transition" title="清空历史"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /> |
| </svg> |
| </button> |
| </div> |
| <div class="flex-1 overflow-y-auto p-2 space-y-2 custom-scrollbar"> |
| <div v-if="history.length === 0" class="text-center text-slate-700 py-10 text-xs flex flex-col items-center"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 mb-2 opacity-50"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> |
| </svg> |
| 暂无记录 |
| </div> |
| <div v-for="(item, index) in history" :key="index" |
| @click="loadHistory(item)" |
| class="group p-3 rounded-lg hover:bg-slate-800 cursor-pointer border border-transparent hover:border-slate-700 transition-all relative"> |
| <div class="flex items-center justify-between mb-1"> |
| <span :class="getMethodColor(item.method)" class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-opacity-10 w-auto">${ item.method }</span> |
| <span class="text-[10px] text-slate-500">${ formatDate(item.timestamp) }</span> |
| </div> |
| <div class="text-xs text-slate-300 truncate font-mono mb-1 opacity-80 group-hover:opacity-100" :title="item.url">${ item.url }</div> |
| <div class="flex items-center justify-between mt-1"> |
| <div class="flex items-center space-x-2"> |
| <span v-if="item.status" :class="getStatusColor(item.status)" class="text-[10px] px-1.5 py-0.5 rounded-full bg-opacity-20 font-medium"> |
| ${ item.status } |
| </span> |
| <span class="text-[10px] text-slate-600">${ item.duration }ms</span> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 flex flex-col min-w-0 bg-slate-900 relative"> |
| |
| <button @click="showSidebar = !showSidebar" class="absolute top-1/2 left-0 -translate-y-1/2 z-10 bg-slate-800 border border-slate-700 text-slate-400 p-1 rounded-r shadow hover:text-white" style="width: 16px; height: 32px; display: flex; align-items: center; justify-content: center;"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3"> |
| <path v-if="showSidebar" stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> |
| <path v-else stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| </svg> |
| </button> |
|
|
| |
| <div class="p-4 border-b border-slate-800 bg-slate-800/30 backdrop-blur-sm z-10"> |
| <div class="flex space-x-2"> |
| <select v-model="currentRequest.method" class="bg-slate-800 border border-slate-600 text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-blue-500 font-bold w-28 transition shadow-sm hover:border-slate-500"> |
| <option value="GET">GET</option> |
| <option value="POST">POST</option> |
| <option value="PUT">PUT</option> |
| <option value="DELETE">DELETE</option> |
| <option value="PATCH">PATCH</option> |
| <option value="HEAD">HEAD</option> |
| <option value="OPTIONS">OPTIONS</option> |
| </select> |
| <div class="flex-1 relative group"> |
| <input type="text" v-model="currentRequest.url" |
| placeholder="输入 URL (例如: https://httpbin.org/get)" |
| @keyup.enter="sendRequest" |
| class="w-full bg-slate-800 border border-slate-600 text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:border-blue-500 font-mono transition shadow-sm hover:border-slate-500"> |
| <div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-slate-600 group-focus-within:text-blue-500 transition"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /> |
| </svg> |
| </div> |
| </div> |
| <button @click="sendRequest" :disabled="loading" |
| class="bg-blue-600 hover:bg-blue-500 text-white px-8 py-2.5 rounded-lg font-medium transition shadow-lg shadow-blue-900/50 flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none transform active:scale-95"> |
| <svg v-if="!loading" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" /> |
| </svg> |
| <svg v-else class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| 发送 |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 flex flex-col md:flex-row overflow-hidden"> |
| |
| |
| <div class="flex-1 flex flex-col border-r border-slate-800 min-w-[300px]"> |
| |
| <div class="flex border-b border-slate-800 bg-slate-900"> |
| <button v-for="tab in ['Params', 'Headers', 'Body', 'Auth']" |
| @click="activeReqTab = tab" |
| :class="activeReqTab === tab ? 'text-blue-400 border-b-2 border-blue-400 bg-slate-800/50' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/30'" |
| class="px-6 py-3 text-sm font-medium transition flex items-center"> |
| ${ tab } |
| <span v-if="tab === 'Params' && paramsCount > 0" class="ml-2 text-[10px] bg-blue-900 text-blue-300 px-1.5 py-0.5 rounded-full font-bold">${ paramsCount }</span> |
| <span v-if="tab === 'Headers' && headersCount > 0" class="ml-2 text-[10px] bg-blue-900 text-blue-300 px-1.5 py-0.5 rounded-full font-bold">${ headersCount }</span> |
| </button> |
| </div> |
|
|
| |
| <div class="flex-1 overflow-y-auto p-4 bg-slate-900"> |
| |
| |
| <div v-if="activeReqTab === 'Params'" class="animate-fade-in"> |
| <div class="space-y-3"> |
| <div class="flex justify-between items-center mb-2"> |
| <div class="text-xs text-slate-500 uppercase tracking-wider font-semibold">Query Parameters</div> |
| <button @click="addParam" class="text-xs text-blue-400 hover:text-blue-300 flex items-center bg-slate-800 px-2 py-1 rounded hover:bg-slate-700 transition"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3 mr-1"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> |
| </svg> |
| 添加参数 |
| </button> |
| </div> |
| <div v-if="currentRequest.params.length === 0" class="text-center text-slate-600 py-8 text-sm italic"> |
| 没有查询参数 |
| </div> |
| <div v-for="(param, idx) in currentRequest.params" :key="idx" class="flex space-x-2 group items-center"> |
| <div class="pt-0"> |
| <input type="checkbox" v-model="param.active" class="rounded bg-slate-700 border-slate-600 text-blue-500 focus:ring-offset-slate-900"> |
| </div> |
| <input type="text" v-model="param.key" placeholder="Key" class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-300 focus:border-blue-500 outline-none transition placeholder-slate-600"> |
| <input type="text" v-model="param.value" placeholder="Value" class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-300 focus:border-blue-500 outline-none transition placeholder-slate-600"> |
| <button @click="removeParam(idx)" class="text-slate-600 hover:text-red-400 opacity-0 group-hover:opacity-100 px-2 transition"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div v-if="activeReqTab === 'Headers'" class="animate-fade-in"> |
| <div class="space-y-3"> |
| <div class="flex justify-between items-center mb-2"> |
| <div class="text-xs text-slate-500 uppercase tracking-wider font-semibold">Request Headers</div> |
| <button @click="addHeader" class="text-xs text-blue-400 hover:text-blue-300 flex items-center bg-slate-800 px-2 py-1 rounded hover:bg-slate-700 transition"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3 mr-1"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> |
| </svg> |
| 添加 Header |
| </button> |
| </div> |
| <div v-for="(header, idx) in currentRequest.headers" :key="idx" class="flex space-x-2 group items-center"> |
| <div class="pt-0"> |
| <input type="checkbox" v-model="header.active" class="rounded bg-slate-700 border-slate-600 text-blue-500 focus:ring-offset-slate-900"> |
| </div> |
| <input type="text" v-model="header.key" placeholder="Key" class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-300 focus:border-blue-500 outline-none transition placeholder-slate-600 font-mono text-xs"> |
| <input type="text" v-model="header.value" placeholder="Value" class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-300 focus:border-blue-500 outline-none transition placeholder-slate-600"> |
| <button @click="removeHeader(idx)" class="text-slate-600 hover:text-red-400 opacity-0 group-hover:opacity-100 px-2 transition"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div v-if="activeReqTab === 'Body'" class="animate-fade-in h-full flex flex-col"> |
| <div class="flex items-center space-x-6 mb-4 px-1"> |
| <label class="flex items-center space-x-2 cursor-pointer group"> |
| <input type="radio" v-model="currentRequest.bodyType" value="none" class="text-blue-500 focus:ring-blue-500 bg-slate-700 border-slate-600 focus:ring-offset-slate-900"> |
| <span class="text-sm text-slate-400 group-hover:text-slate-200 transition">None</span> |
| </label> |
| <label class="flex items-center space-x-2 cursor-pointer group"> |
| <input type="radio" v-model="currentRequest.bodyType" value="json" class="text-blue-500 focus:ring-blue-500 bg-slate-700 border-slate-600 focus:ring-offset-slate-900"> |
| <span class="text-sm text-slate-400 group-hover:text-slate-200 transition">JSON</span> |
| </label> |
| <label class="flex items-center space-x-2 cursor-pointer group"> |
| <input type="radio" v-model="currentRequest.bodyType" value="text" class="text-blue-500 focus:ring-blue-500 bg-slate-700 border-slate-600 focus:ring-offset-slate-900"> |
| <span class="text-sm text-slate-400 group-hover:text-slate-200 transition">Text</span> |
| </label> |
| </div> |
| <div v-if="currentRequest.bodyType === 'json'" class="flex-1 relative border border-slate-700 rounded-lg overflow-hidden"> |
| <button @click="formatBody" class="absolute top-2 right-2 z-10 text-xs bg-slate-800 hover:bg-slate-700 text-slate-300 px-2 py-1 rounded border border-slate-600 transition opacity-70 hover:opacity-100"> |
| Format JSON |
| </button> |
| <textarea v-model="currentRequest.bodyContent" |
| class="w-full h-full bg-slate-800 p-4 font-mono text-sm text-green-400 focus:border-blue-500 outline-none resize-none code-editor" |
| placeholder="{ 'key': 'value' }"></textarea> |
| </div> |
| <div v-if="currentRequest.bodyType === 'text'" class="flex-1 border border-slate-700 rounded-lg overflow-hidden"> |
| <textarea v-model="currentRequest.bodyContent" |
| class="w-full h-full bg-slate-800 p-4 font-mono text-sm text-slate-300 focus:border-blue-500 outline-none resize-none" |
| placeholder="Raw text content..."></textarea> |
| </div> |
| <div v-if="currentRequest.bodyType === 'none'" class="flex-1 flex flex-col items-center justify-center text-slate-600 italic border border-dashed border-slate-800 rounded-lg"> |
| <span class="mb-2">This request has no body</span> |
| <span class="text-xs opacity-50">Select JSON or Text to add content</span> |
| </div> |
| </div> |
|
|
| |
| <div v-if="activeReqTab === 'Auth'" class="animate-fade-in"> |
| <div class="bg-slate-800/50 p-6 rounded-lg border border-slate-700 text-center"> |
| <div class="text-sm text-slate-400 mb-4">目前仅支持通过 Headers 添加认证信息。</div> |
| <button @click="addAuthHeader" class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded text-sm transition shadow-lg shadow-blue-900/30"> |
| 添加 Bearer Token Header |
| </button> |
| <p class="text-xs text-slate-500 mt-4">更多认证方式(Basic Auth, OAuth2)正在开发中...</p> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 flex flex-col min-w-[300px] bg-slate-900 border-l border-slate-800"> |
| <div v-if="response" class="h-full flex flex-col"> |
| |
| <div class="px-4 py-3 border-b border-slate-800 bg-slate-800/20 flex justify-between items-center"> |
| <div class="flex items-center space-x-6"> |
| <span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Status</span> |
| <span :class="getStatusColor(response.status)" class="font-bold text-sm px-2 py-0.5 rounded-full">${ response.status } ${ response.status_text }</span> |
| |
| <div class="h-4 w-px bg-slate-700"></div> |
|
|
| <span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Time</span> |
| <span class="text-sm text-slate-200 font-mono">${ response.duration }ms</span> |
|
|
| <div class="h-4 w-px bg-slate-700"></div> |
|
|
| <span class="text-xs font-medium text-slate-400 uppercase tracking-wider">Size</span> |
| <span class="text-sm text-slate-200 font-mono">${ formatSize(response.size) }</span> |
| </div> |
| <div class="flex space-x-2"> |
| <button @click="copyResponse" class="text-slate-400 hover:text-white text-xs bg-slate-800 px-2 py-1 rounded border border-slate-700 transition flex items-center" title="复制结果"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3 mr-1"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5" /> |
| </svg> |
| Copy |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 overflow-hidden relative group bg-slate-900"> |
| <div v-if="response.is_binary" class="w-full h-full flex flex-col items-center justify-center p-4 overflow-auto"> |
| <div v-if="response.content_type && response.content_type.startsWith('image/')" class="text-center"> |
| <img :src="`data:${response.content_type};base64,${response.data}`" class="max-w-full max-h-[70vh] shadow-2xl rounded-lg border border-slate-700 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iIzMzMyIgZmlsbC1vcGFjaXR5PSIwLjEiPjxwYXRoIGQ9Ik0wIDBoMTB2MTBIMHptMTAgMTBoMTB2MTBIMTB6Ii8+PC9zdmc+')] bg-repeat" /> |
| <div class="mt-4 px-3 py-1 bg-slate-800 rounded-full inline-block text-xs text-slate-400 border border-slate-700 font-mono">${ response.content_type }</div> |
| </div> |
| <div v-else class="text-center"> |
| <div class="w-20 h-20 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-4 border border-slate-700"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10 text-slate-500"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /> |
| </svg> |
| </div> |
| <p class="text-slate-300 font-medium text-lg">Binary Content</p> |
| <p class="text-xs text-slate-500 mt-1 font-mono">${ response.content_type }</p> |
| <p class="text-xs text-slate-600 mt-4 bg-slate-800/50 px-4 py-2 rounded">Preview unavailable for this file type</p> |
| </div> |
| </div> |
| <div v-else class="w-full h-full relative"> |
| <pre v-if="response.is_json" class="w-full h-full p-4 overflow-auto text-sm font-mono language-json custom-scrollbar"><code class="language-json">${ formatResponseData(response.data) }</code></pre> |
| <pre v-else class="w-full h-full p-4 overflow-auto text-sm font-mono text-slate-300 whitespace-pre-wrap custom-scrollbar">${ response.data }</pre> |
| </div> |
| </div> |
| </div> |
| |
| <div v-else class="flex-1 flex flex-col items-center justify-center text-slate-600"> |
| <div class="w-24 h-24 bg-slate-800/50 rounded-full flex items-center justify-center mb-6"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 opacity-30"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" /> |
| </svg> |
| </div> |
| <p class="text-lg font-medium text-slate-500">Ready to Request</p> |
| <p class="text-sm text-slate-600 mt-2">Enter URL and click Send to see the response</p> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div v-if="showImportModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50"> |
| <div class="bg-slate-800 border border-slate-700 rounded-lg shadow-2xl w-[600px] max-w-full m-4 p-6 animate-scale-in"> |
| <h3 class="text-lg font-bold text-white mb-4">Import cURL</h3> |
| <textarea v-model="curlInput" class="w-full h-40 bg-slate-900 border border-slate-700 rounded p-3 font-mono text-sm text-slate-300 focus:border-blue-500 outline-none resize-none mb-4" placeholder="Paste cURL command here..."></textarea> |
| <div class="flex justify-end space-x-3"> |
| <button @click="showImportModal = false" class="px-4 py-2 rounded text-slate-400 hover:text-white hover:bg-slate-700 transition text-sm">Cancel</button> |
| <button @click="importCurl" class="px-4 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white font-medium transition text-sm">Import</button> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const { createApp, ref, computed, onMounted, nextTick } = Vue; |
| |
| createApp({ |
| delimiters: ['${', '}'], |
| setup() { |
| const loading = ref(false); |
| const activeReqTab = ref('Params'); |
| const history = ref([]); |
| const response = ref(null); |
| const showSidebar = ref(true); |
| const showImportModal = ref(false); |
| const curlInput = ref(''); |
| |
| const currentRequest = ref({ |
| method: 'GET', |
| url: 'https://httpbin.org/get', |
| params: [{ key: 'hello', value: 'world', active: true }], |
| headers: [ |
| { key: 'User-Agent', value: 'HttpRequestStudio/1.0', active: true }, |
| { key: 'Accept', value: 'application/json', active: true } |
| ], |
| bodyType: 'none', |
| bodyContent: '' |
| }); |
| |
| |
| onMounted(() => { |
| const saved = localStorage.getItem('http-studio-history'); |
| if (saved) { |
| history.value = JSON.parse(saved); |
| } |
| |
| |
| if (currentRequest.value.headers.length === 0 || (currentRequest.value.headers.length === 1 && !currentRequest.value.headers[0].key)) { |
| currentRequest.value.headers = [ |
| { key: 'User-Agent', value: 'HttpRequestStudio/1.0', active: true }, |
| { key: 'Accept', value: '*/*', active: true } |
| ]; |
| } |
| }); |
| |
| const paramsCount = computed(() => currentRequest.value.params.filter(p => p.key && p.active).length); |
| const headersCount = computed(() => currentRequest.value.headers.filter(h => h.key && h.active).length); |
| |
| const addParam = () => currentRequest.value.params.push({ key: '', value: '', active: true }); |
| const removeParam = (idx) => currentRequest.value.params.splice(idx, 1); |
| |
| const addHeader = () => currentRequest.value.headers.push({ key: '', value: '', active: true }); |
| const removeHeader = (idx) => currentRequest.value.headers.splice(idx, 1); |
| |
| const addAuthHeader = () => { |
| currentRequest.value.headers.push({ key: 'Authorization', value: 'Bearer ', active: true }); |
| activeReqTab.value = 'Headers'; |
| }; |
| |
| const clearHistory = () => { |
| if(confirm('确定清空所有历史记录吗?')) { |
| history.value = []; |
| localStorage.removeItem('http-studio-history'); |
| } |
| }; |
| |
| const formatBody = () => { |
| try { |
| const obj = JSON.parse(currentRequest.value.bodyContent); |
| currentRequest.value.bodyContent = JSON.stringify(obj, null, 2); |
| } catch (e) { |
| alert('JSON 格式无效'); |
| } |
| }; |
| |
| const formatResponseData = (data) => { |
| if (typeof data === 'object') { |
| return JSON.stringify(data, null, 2); |
| } |
| return data; |
| }; |
| |
| const getMethodColor = (method) => { |
| const colors = { |
| 'GET': 'text-green-400', |
| 'POST': 'text-yellow-400', |
| 'PUT': 'text-blue-400', |
| 'DELETE': 'text-red-400', |
| 'PATCH': 'text-purple-400' |
| }; |
| return colors[method] || 'text-slate-400'; |
| }; |
| |
| const getStatusColor = (status) => { |
| if (status >= 200 && status < 300) return 'text-green-400 bg-green-900/50'; |
| if (status >= 300 && status < 400) return 'text-yellow-400 bg-yellow-900/50'; |
| if (status >= 400 && status < 500) return 'text-orange-400 bg-orange-900/50'; |
| if (status >= 500) return 'text-red-400 bg-red-900/50'; |
| return 'text-slate-400 bg-slate-800'; |
| }; |
| |
| const formatSize = (bytes) => { |
| if (bytes === undefined || bytes === null) return '0 B'; |
| if (bytes < 1024) return bytes + ' B'; |
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; |
| return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; |
| }; |
| |
| const formatDate = (ts) => { |
| return new Date(ts).toLocaleTimeString(); |
| }; |
| |
| const loadDemo = (type) => { |
| const demos = { |
| 'get': { |
| method: 'GET', |
| url: 'https://httpbin.org/get', |
| params: [{ key: 'demo_param', value: 'hello_world', active: true }], |
| headers: [{ key: 'Accept', value: 'application/json', active: true }], |
| bodyType: 'none', |
| bodyContent: '' |
| }, |
| 'post': { |
| method: 'POST', |
| url: 'https://httpbin.org/post', |
| params: [], |
| headers: [ |
| { key: 'Content-Type', value: 'application/json', active: true }, |
| { key: 'X-Custom-Header', value: 'demo-value', active: true } |
| ], |
| bodyType: 'json', |
| bodyContent: '{\n "name": "HTTP Request Studio",\n "description": "Powerful API Client",\n "active": true\n}' |
| }, |
| 'image': { |
| method: 'GET', |
| url: 'https://httpbin.org/image/png', |
| params: [], |
| headers: [], |
| bodyType: 'none', |
| bodyContent: '' |
| }, |
| '404': { |
| method: 'GET', |
| url: 'https://httpbin.org/status/404', |
| params: [], |
| headers: [], |
| bodyType: 'none', |
| bodyContent: '' |
| } |
| }; |
| |
| const demo = demos[type]; |
| if (demo) { |
| currentRequest.value = JSON.parse(JSON.stringify(demo)); |
| |
| currentRequest.value.headers.push({ key: 'User-Agent', value: 'HttpRequestStudio/1.0', active: true }); |
| } |
| }; |
| |
| const loadHistory = (item) => { |
| currentRequest.value.method = item.method; |
| currentRequest.value.url = item.url; |
| |
| if (item.headers) { |
| currentRequest.value.headers = Array.isArray(item.headers) ? item.headers : |
| Object.entries(item.headers).map(([k, v]) => ({ key: k, value: v, active: true })); |
| } |
| |
| if (item.params) { |
| currentRequest.value.params = Array.isArray(item.params) ? item.params : |
| Object.entries(item.params).map(([k, v]) => ({ key: k, value: v, active: true })); |
| } |
| |
| if (item.body) { |
| currentRequest.value.bodyContent = item.body; |
| try { |
| JSON.parse(item.body); |
| currentRequest.value.bodyType = 'json'; |
| } catch { |
| currentRequest.value.bodyType = 'text'; |
| } |
| } else { |
| currentRequest.value.bodyType = 'none'; |
| currentRequest.value.bodyContent = ''; |
| } |
| }; |
| |
| const copyResponse = () => { |
| if (!response.value) return; |
| const content = response.value.is_json |
| ? JSON.stringify(response.value.data, null, 2) |
| : response.value.data; |
| |
| navigator.clipboard.writeText(content).then(() => { |
| alert('已复制到剪贴板'); |
| }); |
| }; |
| |
| const importCurl = () => { |
| const curl = curlInput.value.trim(); |
| if (!curl) return; |
| |
| |
| |
| try { |
| const methodMatch = curl.match(/-X\s+([A-Z]+)/) || curl.match(/--request\s+([A-Z]+)/); |
| const method = methodMatch ? methodMatch[1] : 'GET'; |
| |
| |
| const urlMatch = curl.match(/['"](http[s]?:\/\/[^'"]+)['"]/) || curl.match(/(http[s]?:\/\/[^\s]+)/); |
| const url = urlMatch ? urlMatch[1] : ''; |
| |
| if (!url) { |
| alert('无法解析 URL'); |
| return; |
| } |
| |
| |
| const headers = []; |
| const headerRegex = /-H\s+['"]([^'"]+)['"]/g; |
| let match; |
| while ((match = headerRegex.exec(curl)) !== null) { |
| const [key, value] = match[1].split(/:\s*(.*)/); |
| headers.push({ key: key.trim(), value: value.trim(), active: true }); |
| } |
| |
| |
| const dataMatch = curl.match(/-d\s+['"]([^'"]+)['"]/) || curl.match(/--data\s+['"]([^'"]+)['"]/) || curl.match(/--data-raw\s+['"]([^'"]+)['"]/); |
| const body = dataMatch ? dataMatch[1] : ''; |
| |
| currentRequest.value = { |
| method, |
| url, |
| headers: headers.length ? headers : [{ key: 'User-Agent', value: 'HttpRequestStudio/1.0', active: true }], |
| params: [], |
| bodyType: body ? (isValidJson(body) ? 'json' : 'text') : 'none', |
| bodyContent: body |
| }; |
| |
| showImportModal.value = false; |
| curlInput.value = ''; |
| } catch (e) { |
| alert('解析 cURL 失败: ' + e.message); |
| } |
| }; |
| |
| const isValidJson = (str) => { |
| try { JSON.parse(str); return true; } catch { return false; } |
| }; |
| |
| const sendRequest = async () => { |
| if (!currentRequest.value.url) { |
| alert('请输入 URL'); |
| return; |
| } |
| |
| loading.value = true; |
| response.value = null; |
| |
| |
| const headers = {}; |
| currentRequest.value.headers.forEach(h => { |
| if (h.active && h.key) headers[h.key] = h.value; |
| }); |
| |
| const params = {}; |
| currentRequest.value.params.forEach(p => { |
| if (p.active && p.key) params[p.key] = p.value; |
| }); |
| |
| |
| if (currentRequest.value.bodyType === 'json') { |
| if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'; |
| } |
| |
| const payload = { |
| method: currentRequest.value.method, |
| url: currentRequest.value.url, |
| params: params, |
| headers: headers, |
| body: currentRequest.value.bodyType !== 'none' ? currentRequest.value.bodyContent : null |
| }; |
| |
| try { |
| const res = await fetch('/api/request', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json' |
| }, |
| body: JSON.stringify(payload) |
| }); |
| |
| const data = await res.json(); |
| if (data.error) { |
| alert('请求失败: ' + data.error); |
| response.value = { |
| status: 0, |
| status_text: 'Error', |
| duration: 0, |
| size: 0, |
| data: data.error, |
| is_json: false |
| }; |
| } else { |
| response.value = data; |
| |
| |
| const historyItem = { |
| ...currentRequest.value, |
| timestamp: Date.now(), |
| status: data.status, |
| duration: data.duration |
| }; |
| |
| |
| |
| history.value.unshift(historyItem); |
| if (history.value.length > 50) history.value.pop(); |
| localStorage.setItem('http-studio-history', JSON.stringify(history.value)); |
| } |
| } catch (e) { |
| alert('网络错误: ' + e.message); |
| } finally { |
| loading.value = false; |
| nextTick(() => { |
| if(window.Prism) Prism.highlightAll(); |
| }); |
| } |
| }; |
| |
| return { |
| loading, |
| activeReqTab, |
| history, |
| response, |
| currentRequest, |
| paramsCount, |
| headersCount, |
| showSidebar, |
| showImportModal, |
| curlInput, |
| addParam, |
| removeParam, |
| addHeader, |
| removeHeader, |
| addAuthHeader, |
| clearHistory, |
| formatBody, |
| formatResponseData, |
| getMethodColor, |
| getStatusColor, |
| formatSize, |
| formatDate, |
| sendRequest, |
| loadDemo, |
| loadHistory, |
| copyResponse, |
| importCurl |
| }; |
| } |
| }).mount('#app'); |
| </script> |
| </body> |
| </html> |
|
|