Spaces:
Sleeping
Sleeping
| <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> | |
| <!-- Local Static Assets --> | |
| <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; } | |
| /* Custom Scrollbar for panels */ | |
| ::-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; | |
| } | |
| /* Glassmorphism utils */ | |
| .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 --> | |
| <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"> | |
| <!-- Bolt Icon --> | |
| <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"> | |
| <!-- Demo Dropdown --> | |
| <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> | |
| <!-- Import cURL --> | |
| <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> | |
| <!-- Main Content --> | |
| <div class="flex-1 flex overflow-hidden"> | |
| <!-- Sidebar (History) --> | |
| <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> | |
| <!-- Workspace --> | |
| <div class="flex-1 flex flex-col min-w-0 bg-slate-900 relative"> | |
| <!-- Toggle Sidebar Button --> | |
| <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> | |
| <!-- Request Bar --> | |
| <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> | |
| <!-- Main Split Pane (Request Config / Response) --> | |
| <div class="flex-1 flex flex-col md:flex-row overflow-hidden"> | |
| <!-- Request Config (Left/Top) --> | |
| <div class="flex-1 flex flex-col border-r border-slate-800 min-w-[300px]"> | |
| <!-- Tabs --> | |
| <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> | |
| <!-- Tab Content --> | |
| <div class="flex-1 overflow-y-auto p-4 bg-slate-900"> | |
| <!-- Params Tab --> | |
| <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> | |
| <!-- Headers Tab --> | |
| <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> | |
| <!-- Body Tab --> | |
| <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> | |
| <!-- Auth Tab (Simple) --> | |
| <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> | |
| <!-- Response (Right/Bottom) --> | |
| <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"> | |
| <!-- Response Meta --> | |
| <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> | |
| <!-- Response Body --> | |
| <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> | |
| <!-- Import Modal --> | |
| <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: '' | |
| }); | |
| // Load history from local storage | |
| onMounted(() => { | |
| const saved = localStorage.getItem('http-studio-history'); | |
| if (saved) { | |
| history.value = JSON.parse(saved); | |
| } | |
| // Add default header if empty | |
| 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)); | |
| // Add default user agent | |
| 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; | |
| // Basic cURL parser | |
| // Note: This is a simplified parser | |
| try { | |
| const methodMatch = curl.match(/-X\s+([A-Z]+)/) || curl.match(/--request\s+([A-Z]+)/); | |
| const method = methodMatch ? methodMatch[1] : 'GET'; | |
| // Extract URL (naively) | |
| const urlMatch = curl.match(/['"](http[s]?:\/\/[^'"]+)['"]/) || curl.match(/(http[s]?:\/\/[^\s]+)/); | |
| const url = urlMatch ? urlMatch[1] : ''; | |
| if (!url) { | |
| alert('无法解析 URL'); | |
| return; | |
| } | |
| // Extract Headers | |
| 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 }); | |
| } | |
| // Extract Data | |
| 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; | |
| // Prepare payload | |
| 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; | |
| }); | |
| // Set Content-Type if JSON body | |
| 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; | |
| // Save to history | |
| const historyItem = { | |
| ...currentRequest.value, | |
| timestamp: Date.now(), | |
| status: data.status, | |
| duration: data.duration | |
| }; | |
| // Remove deep clone of headers/params for storage to save space, but here we keep structure | |
| // Limit history size | |
| 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> | |