g2api / vue-ui /src /components /AppHeader.vue
LerinaOwO's picture
Upload 98 files
097fb32 verified
<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();
// 检查无 token 时是否还能访问(open access 模式),能则不跳转登录页
try {
const res = await fetch('/api/vue/stats');
if (res.ok) {
// 服务端不需要授权,保持登录状态
return;
}
} catch { /* ignore */ }
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>