| <template> |
| <header class="app-header"> |
| <div class="header-left"> |
| <h1><span class="ic">⚡</span> Cursor2API 日志</h1> |
| </div> |
| <div class="header-center"> |
| <div class="stats-pills"> |
| <div class="sc" title="总请求数"><b>{{ stats.totalRequests }}</b> 请求</div> |
| <div class="sc sc-ok" title="成功完成的请求数">✅ <b>{{ stats.successCount }}</b></div> |
| <div class="sc sc-deg" title="降级请求数(重试后成功)">⚠️ <b>{{ stats.degradedCount }}</b></div> |
| <div class="sc sc-err" title="失败请求数">❌ <b>{{ stats.errorCount }}</b></div> |
| <div class="sc" v-if="stats.avgResponseTime" title="平均响应时间(从收到请求到流式结束)">⏱ <b>{{ fmtMs(stats.avgResponseTime) }}</b></div> |
| <div class="sc" v-if="stats.avgTTFT" title="平均首 Token 时间(Time To First Token)">⚡ <b>{{ fmtMs(stats.avgTTFT) }}</b> TTFT</div> |
| </div> |
| </div> |
| <div class="header-right"> |
| <button v-if="loggedIn && authStore.token" class="hdr-btn logout-btn" @click="onLogout" title="退出登录">退出</button> |
| <button class="hdr-btn config-btn" @click="emit('openConfig')" title="打开配置面板"> |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <circle cx="12" cy="12" r="3"/> |
| <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/> |
| </svg> |
| 配置 |
| </button> |
| <button class="hdr-btn clear-btn" @click="onClear" title="清空所有日志(不可恢复)">🗑 清空</button> |
| <button class="hdr-btn theme-btn" @click="toggleTheme" :title="isDark ? '切换到浅色主题' : '切换到深色主题'">{{ isDark ? '☀️' : '🌙' }}</button> |
| <div class="conn" :class="connected ? 'on' : 'off'"> |
| <div class="d" /> |
| <span>{{ connected ? '已连接' : '重连中…' }}</span> |
| </div> |
| </div> |
| </header> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, onMounted } from 'vue'; |
| import { useStatsStore } from '../stores/stats'; |
| import { useLogsStore } from '../stores/logs'; |
| import { useAuthStore } from '../stores/auth'; |
| import { storeToRefs } from 'pinia'; |
| |
| defineProps<{ connected: boolean }>(); |
| const emit = defineEmits<{ openConfig: [] }>(); |
| |
| const statsStore = useStatsStore(); |
| const logsStore = useLogsStore(); |
| const authStore = useAuthStore(); |
| const { stats } = storeToRefs(statsStore); |
| const { loggedIn } = storeToRefs(authStore); |
| |
| function fmtMs(ms: number): string { |
| return ms >= 1000 ? (ms / 1000).toFixed(2).replace(/\.?0+$/, '') + 's' : ms + 'ms'; |
| } |
| |
| async function onLogout() { |
| authStore.clearToken(); |
| |
| try { |
| const res = await fetch('/api/vue/stats'); |
| if (res.ok) { |
| |
| return; |
| } |
| } catch { } |
| authStore.loggedIn = false; |
| } |
| |
| const isDark = ref(false); |
| |
| onMounted(() => { |
| isDark.value = (localStorage.getItem('cursor2api_theme') ?? 'light') === 'dark'; |
| applyTheme(); |
| }); |
| |
| function applyTheme() { |
| document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light'); |
| } |
| |
| function toggleTheme() { |
| isDark.value = !isDark.value; |
| localStorage.setItem('cursor2api_theme', isDark.value ? 'dark' : 'light'); |
| applyTheme(); |
| } |
| |
| async function onClear() { |
| if (!confirm('确定清空所有日志?此操作不可恢复。')) return; |
| await logsStore.clear(); |
| await statsStore.load(); |
| } |
| </script> |
| |
| <style scoped> |
| .app-header { |
| display: grid; grid-template-columns: 1fr auto 1fr; |
| align-items: center; |
| padding: 14px 20px; |
| background: |
| radial-gradient(ellipse 60% 100% at 50% -20%, rgba(88,166,255,0.10) 0%, transparent 70%), |
| rgba(13,17,23,.95); |
| backdrop-filter: blur(24px) saturate(180%); |
| border-bottom: 1px solid rgba(99,102,241,0.15); |
| flex-shrink: 0; z-index: 10; position: relative; |
| } |
| [data-theme="light"] .app-header { |
| background: |
| radial-gradient(ellipse 60% 100% at 50% -20%, rgba(99,102,241,0.05) 0%, transparent 70%), |
| rgba(255,255,255,.92); |
| border-bottom: 1px solid rgba(226,232,240,.9); |
| box-shadow: 0 1px 6px rgba(0,0,0,.06); |
| } |
| [data-theme="light"] .sc { |
| background: #fff; |
| border-color: #e2e8f0; |
| box-shadow: 0 1px 3px rgba(0,0,0,.05); |
| } |
| .header-left { display: flex; align-items: center; gap: 14px; } |
| .header-center { display: flex; justify-content: center; align-items: center; } |
| h1 { |
| font-size: 16px; font-weight: 700; |
| background: linear-gradient(135deg, #6366f1, #3b82f6, #0891b2); |
| -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; |
| display: flex; align-items: center; gap: 6px; |
| } |
| h1 .ic { font-size: 17px; -webkit-text-fill-color: initial; } |
| .stats-pills { display: flex; gap: 6px; align-items: center; } |
| .sc { |
| padding: 4px 12px; background: var(--bg2); border: 1px solid var(--border); |
| border-radius: 20px; font-size: 11px; color: var(--text-muted); |
| display: flex; align-items: center; gap: 4px; |
| box-shadow: var(--shadow-sm); |
| } |
| .sc b { font-family: var(--mono); color: var(--text); font-weight: 600; margin: 0 1px; } |
| .sc-ok { color: var(--green); } |
| .sc-ok b { color: var(--green); } |
| .sc-deg { color: var(--orange); } |
| .sc-deg b { color: var(--orange); } |
| .sc-err { color: var(--red); } |
| .sc-err b { color: var(--red); } |
| .header-right { display: flex; align-items: center; gap: 8px; justify-content: flex-end; } |
| .hdr-btn { |
| padding: 5px 12px; font-size: 11px; font-weight: 500; |
| background: var(--bg1); border: 1px solid var(--border); |
| border-radius: var(--radius-sm); color: var(--text-muted); |
| cursor: pointer; transition: all .2s; box-shadow: var(--shadow-sm); |
| } |
| .hdr-btn:hover { border-color: var(--text-muted); color: var(--text); } |
| .clear-btn:hover { border-color: var(--red); color: var(--red); } |
| .theme-btn:hover { border-color: var(--accent); color: var(--accent); } |
| .logout-btn:hover { border-color: var(--orange); color: var(--orange); } |
| .config-btn { display: inline-flex; align-items: center; gap: 4px; } |
| .config-btn svg { flex-shrink: 0; } |
| .config-btn:hover { border-color: var(--accent); color: var(--accent); } |
| .conn { |
| display: flex; align-items: center; gap: 5px; |
| font-size: 10px; font-weight: 500; |
| padding: 4px 10px; border-radius: 20px; |
| border: 1px solid var(--border); background: var(--bg1); |
| } |
| .conn.on { color: var(--green); border-color: color-mix(in srgb, var(--green) 30%, transparent); } |
| .conn.off { color: var(--red); border-color: color-mix(in srgb, var(--red) 30%, transparent); } |
| .conn .d { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } |
| .conn.on .d { background: var(--green); animation: pulse 2s infinite; } |
| .conn.off .d { background: var(--red); } |
| @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} } |
| </style> |
| |