| <template> |
| <div class="request-list"> |
| |
| <div class="search"> |
| <div class="sw"> |
| <input |
| ref="searchInput" |
| v-model="logsStore.search" |
| class="si" |
| placeholder="关键字搜索… (Ctrl+K)" |
| /> |
| <button v-if="logsStore.search" class="si-clear" @click="logsStore.search = ''">✕</button> |
| </div> |
| <button |
| v-if="logsStore.curRequestId" |
| class="follow-btn" |
| :class="{ active: logsStore.autoFollow }" |
| @click="toggleAutoFollow" |
| title="开启后自动跟随并选中最新请求" |
| >⚡ 自动跟随</button> |
| </div> |
| |
| <div class="tbar"> |
| <button |
| v-for="t in timeTabs" |
| :key="t.value" |
| class="tb" |
| :class="{ a: logsStore.timeFilter === t.value }" |
| :title="t.title" |
| @click="logsStore.timeFilter = t.value" |
| >{{ t.label }}</button> |
| </div> |
| |
| <div class="fbar"> |
| <button |
| v-for="f in statusTabs" |
| :key="f.value" |
| class="fb" |
| :class="[{ a: logsStore.statusFilter === f.value }, f.value]" |
| :title="f.title" |
| @click="logsStore.statusFilter = f.value" |
| > |
| <span v-if="f.icon" class="fic">{{ f.icon }}</span> |
| <span v-else class="fall-label">全部</span> |
| <span v-if="f.value !== 'all'" class="fc">{{ counts[f.value] }}</span> |
| </button> |
| </div> |
| |
| <div class="rlist" ref="rlistEl"> |
| <div v-if="!logsStore.filteredReqs.length" class="empty"> |
| <div class="ic">📭</div><p>暂无请求</p> |
| </div> |
| <div |
| v-for="req in logsStore.filteredReqs" |
| :key="req.requestId" |
| class="ri" |
| :class="[req.status, { sel: req.requestId === logsStore.curRequestId }]" |
| @click="selectReq(req.requestId)" |
| > |
| <span class="st" :class="req.status" /> |
| <div class="ri-title"> |
| <span class="seq">#{{ seqNum(req.requestId) }}</span> |
| <span class="ri-title-text">{{ req.title || shortModel(req.model) }}</span> |
| </div> |
| <div class="ri-time"> |
| <span v-if="req.endTime" class="dur" title="总响应耗时"> 耗时 {{ fmtMs(req.endTime - req.startTime) }}</span> |
| <span v-if="req.ttft" class="ttft" title="首 Token 时间(Time To First Token)"> ⚡️{{ fmtMs(req.ttft) }}</span> |
| <span class="date">{{ fmtDate(req.startTime) }}</span> |
| </div> |
| <div class="r1"> |
| <span class="rid" title="请求 ID">{{ req.requestId.slice(0, 8) }}</span> |
| <span class="rfmt" :class="req.apiFormat" :title="'API 格式:' + req.apiFormat">{{ req.apiFormat }}</span> |
| <span v-if="req.responseChars" class="rchars" title="响应字符数">{{ fmtN(req.responseChars) }} chars</span> |
| <span v-if="req.inputTokens" class="rchars" :title="'输入 Token:' + req.inputTokens + ',输出 Token:' + (req.outputTokens ?? 0)">↑{{ fmtN(req.inputTokens) }}↓{{ fmtN(req.outputTokens ?? 0) }} tok</span> |
| </div> |
| <div class="rbd"> |
| <span v-if="req.stream" class="bg bg-stream" title="流式响应">Stream</span> |
| <span v-if="req.toolCount > 0" class="bg bg-tool" :title="'工具定义数:' + req.toolCount">T:{{ req.toolCount }}</span> |
| <span v-if="req.toolCallsDetected > 0" class="bg bg-call" :title="'工具调用次数:' + req.toolCallsDetected">C:{{ req.toolCallsDetected }}</span> |
| <span v-if="req.retryCount > 0" class="bg bg-retry" :title="'重试次数:' + req.retryCount">R:{{ req.retryCount }}</span> |
| <span v-if="req.continuationCount > 0" class="bg bg-cont" :title="'续写次数:' + req.continuationCount">+{{ req.continuationCount }}</span> |
| <span v-if="req.thinkingChars > 0" class="bg bg-think" :title="'思考内容字符数:' + req.thinkingChars">🤔 {{ fmtN(req.thinkingChars) }} chars</span> |
| <span v-if="req.status === 'degraded'" class="bg bg-deg" title="请求降级(发生重试但最终成功)">DEGRADED</span> |
| <span v-if="req.status === 'error'" class="bg bg-err" title="请求失败">ERR</span> |
| <span v-if="req.status === 'intercepted'" class="bg bg-int" title="请求被拦截或中断">INTERCEPT</span> |
| </div> |
| <div class="rdbar-bg"><div class="rdbar" :style="durStyle(req)" /></div> |
| <div v-if="req.error" class="rerr">{{ req.error }}</div> |
| </div> |
| </div> |
| |
| <div v-if="logsStore.hasMore" class="load-more"> |
| <button class="lm-btn" :disabled="logsStore.loadingMore" @click="logsStore.loadMoreRequests()"> |
| {{ logsStore.loadingMore ? '加载中...' : `加载更多(已显示 ${logsStore.reqs.length} / ${logsStore.total})` }} |
| </button> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue'; |
| import { useLogsStore } from '../stores/logs'; |
| |
| const searchInput = ref<HTMLInputElement | null>(null); |
| const rlistEl = ref<HTMLElement | null>(null); |
| |
| function onKeydown(e: KeyboardEvent) { |
| if ((e.ctrlKey || e.metaKey) && e.key === 'k') { |
| e.preventDefault(); |
| searchInput.value?.focus(); |
| return; |
| } |
| if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { |
| |
| if (document.activeElement === searchInput.value) return; |
| const list = logsStore.filteredReqs; |
| if (!list.length) return; |
| e.preventDefault(); |
| |
| (document.activeElement as HTMLElement)?.blur(); |
| const cur = logsStore.curRequestId; |
| const idx = cur ? list.findIndex(r => r.requestId === cur) : -1; |
| let next: number; |
| if (e.key === 'ArrowUp') next = idx <= 0 ? list.length - 1 : idx - 1; |
| else next = idx < 0 || idx >= list.length - 1 ? 0 : idx + 1; |
| logsStore.selectRequest(list[next].requestId); |
| nextTick(() => { |
| const el = rlistEl.value?.querySelectorAll('.ri')[next] as HTMLElement | undefined; |
| el?.scrollIntoView({ block: 'nearest' }); |
| }); |
| } |
| } |
| |
| onMounted(() => { window.addEventListener('keydown', onKeydown); }); |
| onUnmounted(() => { window.removeEventListener('keydown', onKeydown); }); |
| |
| const logsStore = useLogsStore(); |
| |
| watch(() => logsStore.autoFollowTriggered, (v) => { |
| if (v) { |
| nextTick(() => { rlistEl.value?.scrollTo({ top: 0, behavior: 'smooth' }); }); |
| } |
| }); |
| |
| const timeTabs = [ |
| { value: 'all' as const, label: '全部', title: '显示全部历史请求' }, |
| { value: '1h' as const, label: '1小时', title: '最近 1 小时的请求' }, |
| { value: '6h' as const, label: '6小时', title: '最近 6 小时的请求' }, |
| { value: 'today' as const, label: '今天', title: '今天 0 点至今的请求' }, |
| { value: '2d' as const, label: '两天', title: '最近 2 天的请求' }, |
| { value: '7d' as const, label: '一周', title: '最近 7 天的请求' }, |
| { value: '30d' as const, label: '一月', title: '最近 30 天的请求' }, |
| ]; |
| |
| const statusTabs = [ |
| { value: 'all' as const, icon: '', label: '全部', title: '显示全部请求' }, |
| { value: 'success' as const, icon: '✅', label: '成功', title: '成功完成的请求' }, |
| { value: 'degraded' as const, icon: '⚠️', label: '降级', title: '降级请求(重试后成功)' }, |
| { value: 'error' as const, icon: '❌', label: '错误', title: '失败请求' }, |
| { value: 'processing' as const, icon: '⏳', label: '处理中', title: '正在处理的请求' }, |
| { value: 'intercepted' as const, icon: '🚫', label: '中断', title: '被拦截/中断的请求' }, |
| ]; |
| |
| const counts = computed(() => logsStore.statusCounts); |
| |
| function fmtDate(ts: number): string { |
| const d = new Date(ts); |
| const month = String(d.getMonth() + 1).padStart(2, '0'); |
| const day = String(d.getDate()).padStart(2, '0'); |
| const time = d.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); |
| return `${month}/${day} ${time}`; |
| } |
| |
| function shortModel(model: string): string { |
| return model.split('/').pop() ?? model; |
| } |
| |
| function fmtN(n: number): string { |
| return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); |
| } |
| |
| function durStyle(req: { endTime?: number; startTime: number; status: string }): Record<string, string> { |
| if (req.status === 'processing') { |
| return { width: '100%', background: 'var(--blue)', animation: 'prog 1.5s ease-in-out infinite' }; |
| } |
| if (!req.endTime) return { width: '0%' }; |
| const ms = req.endTime - req.startTime; |
| |
| const pct = Math.min(100, Math.round(ms / 300)); |
| let color: string; |
| if (ms < 3000) color = 'var(--green)'; |
| else if (ms < 8000) color = 'var(--yellow)'; |
| else if (ms < 20000) color = '#f97316'; |
| else color = 'var(--red)'; |
| return { width: pct + '%', background: color }; |
| } |
| |
| function fmtMs(ms: number): string { |
| return ms >= 1000 ? (ms / 1000).toFixed(2).replace(/\.?0+$/, '') + 's' : ms + 'ms'; |
| } |
| |
| function seqNum(requestId: string): number { |
| const idx = logsStore.reqs.findIndex(r => r.requestId === requestId); |
| return idx < 0 ? 0 : logsStore.reqs.length - idx; |
| } |
| |
| function selectReq(id: string) { |
| if (logsStore.curRequestId === id) { |
| logsStore.deselect(); |
| } else { |
| logsStore.selectRequest(id); |
| } |
| } |
| |
| function toggleAutoFollow() { |
| logsStore.autoFollow = !logsStore.autoFollow; |
| if (logsStore.autoFollow && logsStore.filteredReqs.length) { |
| logsStore.selectRequest(logsStore.filteredReqs[0].requestId); |
| nextTick(() => { rlistEl.value?.scrollTo({ top: 0, behavior: 'smooth' }); }); |
| } |
| } |
| </script> |
| |
| <style scoped> |
| .request-list { |
| width: 370px; flex-shrink: 0; |
| display: flex; flex-direction: column; |
| border-right: 1px solid var(--border); |
| background: var(--bg1); |
| isolation: isolate; |
| } |
| [data-theme="dark"] .request-list { background: rgba(22,27,39,.75); } |
| |
| .search { padding: 8px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 6px; } |
| .sw { position: relative; flex: 1; } |
| .sw::before { content: '🔍'; position: absolute; left: 9px; top: 50%; transform: translateY(-50%); font-size: 11px; pointer-events: none; } |
| .si { |
| width: 100%; padding: 6px 28px 6px 28px; font-size: 12px; |
| background: var(--bg); border: 1px solid var(--border); |
| border-radius: var(--radius); color: var(--text); |
| font-family: var(--mono); outline: none; transition: border-color .2s; |
| } |
| .si:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(59,130,246,.12); } |
| .si::placeholder { color: var(--text-muted); } |
| .si-clear { |
| position: absolute; right: 8px; top: 50%; transform: translateY(-50%); |
| background: none; border: none; cursor: pointer; |
| color: var(--text-muted); font-size: 12px; padding: 0 2px; |
| line-height: 1; display: flex; align-items: center; |
| } |
| .si-clear:hover { color: var(--text); } |
| .follow-btn { |
| padding: 4px 8px; font-size: 10px; font-weight: 500; white-space: nowrap; flex-shrink: 0; |
| background: var(--bg); border: 1px solid var(--border); border-radius: 20px; |
| color: var(--text-muted); cursor: pointer; transition: all .15s; |
| } |
| .follow-btn:hover { border-color: var(--yellow); color: var(--yellow); } |
| .follow-btn.active { background: color-mix(in srgb, var(--yellow) 15%, transparent); border-color: var(--yellow); color: var(--yellow); font-weight: 600; } |
| |
| .tbar { padding: 5px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 3px; flex-wrap: wrap; } |
| .tb { |
| padding: 3px 9px; font-size: 10px; font-weight: 500; |
| border: 1px solid var(--border); border-radius: 20px; |
| background: var(--bg); color: var(--text-muted); |
| cursor: pointer; transition: all .15s; |
| } |
| .tb:hover { border-color: var(--cyan); color: var(--cyan); } |
| .tb.a { background: linear-gradient(135deg,#0891b2,#06b6d4); border-color: transparent; color: #fff; } |
| |
| .fbar { padding: 5px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 3px; flex-wrap: wrap; } |
| .fb { |
| padding: 3px 8px; font-size: 10px; font-weight: 500; |
| border: 1px solid var(--border); border-radius: 20px; |
| background: var(--bg); color: var(--text-muted); |
| cursor: pointer; transition: all .15s; |
| display: flex; align-items: center; gap: 4px; |
| } |
| .fb:hover { border-color: var(--blue); color: var(--blue); } |
| .fb.a { background: linear-gradient(135deg,#3b82f6,#6366f1); border-color: transparent; color: #fff; } |
| .fb.a.success { background: none; border-color: var(--green); color: var(--green); } |
| .fb.a.degraded { background: none; border-color: var(--orange); color: var(--orange); } |
| .fb.a.error { background: none; border-color: var(--red); color: var(--red); } |
| .fb.a.processing { background: none; border-color: var(--yellow); color: var(--yellow); } |
| .fb.a.intercepted { background: none; border-color: var(--pink); color: var(--pink); } |
| .fic { font-size: 12px; line-height: 1; } |
| .fall-label { font-size: 10px; } |
| .fc { font-size: 9px; font-weight: 700; padding: 0 4px; border-radius: 8px; background: rgba(255,255,255,.15); } |
| .fb:not(.a) .fc { background: var(--pill-bg); color: var(--text-muted); } |
| |
| .rlist { overflow-y: auto; flex: 1; padding: 4px 0; } |
| .empty { padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px; } |
| .empty .ic { font-size: 20px; margin-bottom: 8px; } |
| |
| .ri { |
| position: relative; |
| padding: 9px 12px 6px 14px; cursor: pointer; |
| margin: 4px 8px; |
| border-radius: 8px; |
| border: 1px solid var(--border-faint); |
| transition: background .1s, border-color .1s; |
| overflow: hidden; |
| } |
| .ri:hover { background: var(--hover-bg); border-color: var(--border); } |
| .ri.sel { |
| background: linear-gradient(90deg, color-mix(in srgb, var(--blue) 10%, transparent) 0%, transparent 100%); |
| border-color: var(--border-faint); |
| border-left: 3px solid var(--blue); |
| padding-left: 13px; |
| } |
| |
| |
| .st { |
| position: absolute; top: 10px; right: 10px; |
| width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; |
| background: var(--text-muted); |
| } |
| .st.success { background: var(--green); } |
| .st.degraded { background: var(--orange); } |
| .st.error { background: var(--red); } |
| .st.processing { background: var(--yellow); animation: pulse 1s infinite; } |
| .st.intercepted { background: var(--pink); } |
| @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} } |
| |
| |
| .ri-title { |
| display: flex; align-items: center; gap: 5px; |
| padding-right: 14px; margin-bottom: 3px; min-width: 0; |
| } |
| .seq { font-size: 10px; font-family: var(--mono); color: var(--blue); font-weight: 700; flex-shrink: 0; } |
| .ri-title-text { |
| font-size: 12px; font-weight: 600; color: var(--text); |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; |
| } |
| |
| |
| .ri-time { |
| display: flex; align-items: center; gap: 4px; |
| font-size: 10px; font-family: var(--mono); |
| color: var(--text-muted); margin-bottom: 3px; |
| } |
| .ri-time .date { margin-left: auto; } |
| .ri-time .dur { color: var(--text-muted); } |
| .ri-time .ttft { color: var(--yellow); } |
| |
| |
| .r1 { display: flex; align-items: center; gap: 5px; margin-bottom: 4px; } |
| .rid { font-size: 10px; font-family: var(--mono); color: var(--text-muted); flex-shrink: 0; } |
| .rfmt { |
| font-size: 9px; font-weight: 700; padding: 1px 5px; |
| border-radius: 3px; text-transform: uppercase; |
| background: var(--pill-bg); color: var(--text-muted); |
| } |
| .rfmt.anthropic { background: #7c3aed22; color: #a78bfa; } |
| .rfmt.openai { background: #05966922; color: #34d399; } |
| .rfmt.responses { background: #0ea5e922; color: #38bdf8; } |
| .rchars { font-size: 10px; font-family: var(--mono); color: var(--text-muted); margin-left: auto; } |
| |
| |
| .rbd { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 5px; } |
| .bg { |
| font-size: 9px; font-weight: 600; padding: 1px 5px; |
| border-radius: 3px; line-height: 1.5; |
| } |
| .bg-stream { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } |
| .bg-tool { background: color-mix(in srgb, var(--blue) 15%, transparent); color: var(--blue); } |
| .bg-call { background: color-mix(in srgb, var(--cyan) 15%, transparent); color: var(--cyan); } |
| .bg-retry { background: color-mix(in srgb, var(--yellow) 15%, transparent); color: var(--yellow); } |
| .bg-cont { background: color-mix(in srgb, var(--purple) 15%, transparent); color: var(--purple); } |
| .bg-think { background: color-mix(in srgb, var(--text-muted) 15%, transparent); color: var(--text-muted); } |
| .bg-deg { background: color-mix(in srgb, var(--orange) 15%, transparent); color: var(--orange); } |
| .bg-err { background: color-mix(in srgb, var(--red) 15%, transparent); color: var(--red); } |
| .bg-int { background: color-mix(in srgb, var(--pink) 15%, transparent); color: var(--pink); } |
| |
| |
| .rdbar-bg { |
| height: 3px; |
| background: var(--border-faint); |
| margin: 4px 0 0 0; |
| border-radius: 2px; |
| overflow: hidden; |
| } |
| .rdbar { |
| height: 100%; |
| border-radius: 2px; |
| transition: width .4s ease; |
| } |
| @keyframes prog { 0%,100%{opacity:.4} 50%{opacity:1} } |
| |
| .rerr { color: var(--red); margin-top: 3px; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .load-more { padding: 8px; text-align: center; } |
| .lm-btn { width: 100%; padding: 6px 0; font-size: 12px; color: var(--text-muted); background: var(--bg-2); border: 1px solid var(--border-faint); border-radius: 4px; cursor: pointer; } |
| .lm-btn:hover:not(:disabled) { background: var(--bg-3); color: var(--text); } |
| .lm-btn:disabled { opacity: 0.5; cursor: default; } |
| </style> |
| |