mirror of
https://github.com/7836246/cursor2api.git
synced 2026-09-03 07:20:02 +08:00
feat: Vue UI 增强 — 统计/筛选联动、自动跟随、交互优化
- 新增 getVueStats(since?)/apiGetVueStats/GET /api/vue/stats,供 Vue UI 专用 SQLite 模式走数据库全量聚合+since过滤,getStats() 保持原样不影响 HTML 版本 - Vue UI 所有 /api/stats 调用改为 /api/vue/stats - SSE stats 事件改为调 statsStore.load() 从 /api/vue/stats 拉最新数据 - 时间筛选增加 1小时/6小时选项,状态筛选改为 emoji 图标风格 - 状态筛选选中态:全部用蓝紫渐变底色,图标按钮用对应颜色边框+文字 - AppHeader/RequestList 缩写处全面补充 title tooltip - 新增「自动跟随」功能:选中记录后可开启,SSE 推送新请求时自动切换并滚动到顶部 - 修复 ConfigDrawer draft 可能为 null 的 TS 错误 - 修复 CSS typo:.rfmt.responses background 颜色值多余空格 - upsertRequest 改用 Object.assign 避免 SSE 推送时替换对象引用打断 hover
This commit is contained in:
@@ -11,7 +11,7 @@ import express from 'express';
|
||||
import { getConfig, initConfigWatcher, stopConfigWatcher } from './config.js';
|
||||
import { handleMessages, listModels, countTokens } from './handler.js';
|
||||
import { handleOpenAIChatCompletions, handleOpenAIResponses } from './openai-handler.js';
|
||||
import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream, serveLogViewerLogin, apiClearLogs, serveVueApp, apiGetRequestsMore } from './log-viewer.js';
|
||||
import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetVueStats, apiGetPayload, apiLogsStream, serveLogViewerLogin, apiClearLogs, serveVueApp, apiGetRequestsMore } from './log-viewer.js';
|
||||
import { apiGetConfig, apiSaveConfig } from './config-api.js';
|
||||
import { loadLogsFromFiles } from './logger.js';
|
||||
import { initDb } from './logger-db.js';
|
||||
@@ -72,6 +72,7 @@ app.get('/api/logs', logViewerAuth, apiGetLogs);
|
||||
app.get('/api/requests/more', logViewerAuth, apiGetRequestsMore);
|
||||
app.get('/api/requests', logViewerAuth, apiGetRequests);
|
||||
app.get('/api/stats', logViewerAuth, apiGetStats);
|
||||
app.get('/api/vue/stats', logViewerAuth, apiGetVueStats);
|
||||
app.get('/api/payload/:requestId', logViewerAuth, apiGetPayload);
|
||||
app.get('/api/logs/stream', logViewerAuth, apiLogsStream);
|
||||
app.post('/api/logs/clear', logViewerAuth, apiClearLogs);
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Request, Response } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getAllLogs, getRequestSummaries, getStats, getRequestPayload, subscribeToLogs, subscribeToSummaries, clearAllLogs, getRequestSummariesPage } from './logger.js';
|
||||
import { getAllLogs, getRequestSummaries, getStats, getVueStats, getRequestPayload, subscribeToLogs, subscribeToSummaries, clearAllLogs, getRequestSummariesPage } from './logger.js';
|
||||
|
||||
// ==================== 静态文件路径 ====================
|
||||
|
||||
@@ -35,10 +35,15 @@ export function apiGetRequests(req: Request, res: Response): void {
|
||||
res.json(getRequestSummaries(req.query.limit ? parseInt(req.query.limit as string) : 50));
|
||||
}
|
||||
|
||||
export function apiGetStats(_req: Request, res: Response): void {
|
||||
export function apiGetStats(req: Request, res: Response): void {
|
||||
res.json(getStats());
|
||||
}
|
||||
|
||||
export function apiGetVueStats(req: Request, res: Response): void {
|
||||
const since = req.query.since ? parseInt(req.query.since as string) : undefined;
|
||||
res.json(getVueStats(since));
|
||||
}
|
||||
|
||||
/** GET /api/payload/:requestId - 获取请求的完整参数和响应 */
|
||||
export function apiGetPayload(req: Request, res: Response): void {
|
||||
const payload = getRequestPayload(req.params.requestId as string);
|
||||
|
||||
@@ -167,6 +167,51 @@ export function dbGetSummariesSince(cutoffTimestamp: number): DbRequestSummary[]
|
||||
}).filter((s): s is DbRequestSummary => s !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合统计:通过 SQL 一次查询返回全量(或指定时间范围内)的 stats。
|
||||
* 仅在 db_enabled 时调用。
|
||||
*/
|
||||
export function dbGetStats(since?: number): {
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
degradedCount: number;
|
||||
errorCount: number;
|
||||
interceptedCount: number;
|
||||
processingCount: number;
|
||||
avgResponseTime: number;
|
||||
avgTTFT: number;
|
||||
} {
|
||||
const where = since !== undefined ? 'WHERE timestamp >= ?' : '';
|
||||
const params = since !== undefined ? [since] : [];
|
||||
const row = getDb().prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN json_extract(summary_json,'$.status')='success' THEN 1 ELSE 0 END) as success,
|
||||
SUM(CASE WHEN json_extract(summary_json,'$.status')='degraded' THEN 1 ELSE 0 END) as degraded,
|
||||
SUM(CASE WHEN json_extract(summary_json,'$.status')='error' THEN 1 ELSE 0 END) as error,
|
||||
SUM(CASE WHEN json_extract(summary_json,'$.status')='intercepted' THEN 1 ELSE 0 END) as intercepted,
|
||||
SUM(CASE WHEN json_extract(summary_json,'$.status')='processing' THEN 1 ELSE 0 END) as processing,
|
||||
AVG(CASE WHEN json_extract(summary_json,'$.endTime') IS NOT NULL
|
||||
THEN json_extract(summary_json,'$.endTime') - timestamp END) as avgTime,
|
||||
AVG(CASE WHEN json_extract(summary_json,'$.ttft') IS NOT NULL
|
||||
THEN json_extract(summary_json,'$.ttft') END) as avgTTFT
|
||||
FROM requests ${where}
|
||||
`).get(...params) as {
|
||||
total: number; success: number; degraded: number; error: number;
|
||||
intercepted: number; processing: number; avgTime: number | null; avgTTFT: number | null;
|
||||
};
|
||||
return {
|
||||
totalRequests: row.total ?? 0,
|
||||
successCount: row.success ?? 0,
|
||||
degradedCount: row.degraded ?? 0,
|
||||
errorCount: row.error ?? 0,
|
||||
interceptedCount: row.intercepted ?? 0,
|
||||
processingCount: row.processing ?? 0,
|
||||
avgResponseTime: row.avgTime != null ? Math.round(row.avgTime) : 0,
|
||||
avgTTFT: row.avgTTFT != null ? Math.round(row.avgTTFT) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 清空 ====================
|
||||
|
||||
export function dbClear(): void {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { EventEmitter } from 'events';
|
||||
import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
import { getConfig, onConfigReload } from './config.js';
|
||||
import { initDb, closeDb, isDbInitialized, dbInsertRequest, dbGetPayload, dbGetSummaries, dbCountSummaries, dbGetSummaryCount, dbGetStatusCounts, dbGetSummariesSince, dbClear } from './logger-db.js';
|
||||
import { initDb, closeDb, isDbInitialized, dbInsertRequest, dbGetPayload, dbGetSummaries, dbCountSummaries, dbGetSummaryCount, dbGetStatusCounts, dbGetSummariesSince, dbClear, dbGetStats } from './logger-db.js';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
@@ -705,6 +705,19 @@ export function getStats() {
|
||||
};
|
||||
}
|
||||
|
||||
export function getVueStats(since?: number) {
|
||||
const cfg = getConfig();
|
||||
if (cfg.logging?.db_enabled) {
|
||||
try {
|
||||
return { ...dbGetStats(since), totalLogEntries: logEntries.length };
|
||||
} catch (e) {
|
||||
console.warn('[Logger] dbGetStats 失败,降级到内存:', e);
|
||||
}
|
||||
}
|
||||
// 内存模式:since 参数忽略,数据本就有限,直接复用 getStats()
|
||||
return getStats();
|
||||
}
|
||||
|
||||
// ==================== 核心 API ====================
|
||||
|
||||
export function createRequestLogger(opts: {
|
||||
|
||||
@@ -51,7 +51,7 @@ onMounted(async () => {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/stats', {
|
||||
const res = await fetch('/api/vue/stats', {
|
||||
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
|
||||
});
|
||||
if (res.ok) {
|
||||
|
||||
@@ -30,8 +30,9 @@ export function fetchRequests(limit = 50): Promise<RequestSummary[]> {
|
||||
return apiFetch<RequestSummary[]>(`/api/requests?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function fetchStats(): Promise<Stats> {
|
||||
return apiFetch<Stats>('/api/stats');
|
||||
export function fetchStats(since?: number): Promise<Stats> {
|
||||
const qs = since !== undefined ? `?since=${since}` : '';
|
||||
return apiFetch<Stats>(`/api/vue/stats${qs}`);
|
||||
}
|
||||
|
||||
export function fetchPayload(requestId: string): Promise<Payload> {
|
||||
|
||||
@@ -5,25 +5,25 @@
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<div class="stats-pills">
|
||||
<div class="sc"><b>{{ stats.totalRequests }}</b> 请求</div>
|
||||
<div class="sc sc-ok">✓<b>{{ stats.successCount }}</b></div>
|
||||
<div class="sc sc-deg">!<b>{{ stats.degradedCount }}</b></div>
|
||||
<div class="sc sc-err">✗<b>{{ stats.errorCount }}</b></div>
|
||||
<div class="sc" v-if="stats.avgResponseTime"><b>{{ fmtMs(stats.avgResponseTime) }}</b> 均耗</div>
|
||||
<div class="sc" v-if="stats.avgTTFT">⚡<b>{{ fmtMs(stats.avgTTFT) }}</b> TTFT</div>
|
||||
<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">退出</button>
|
||||
<button class="hdr-btn config-btn" @click="emit('openConfig')" title="配置">
|
||||
<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">🗑 清空</button>
|
||||
<button class="hdr-btn theme-btn" @click="toggleTheme">{{ isDark ? '☀️' : '🌙' }}</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>
|
||||
@@ -56,7 +56,7 @@ async function onLogout() {
|
||||
authStore.clearToken();
|
||||
// 检查无 token 时是否还能访问(open access 模式),能则不跳转登录页
|
||||
try {
|
||||
const res = await fetch('/api/stats');
|
||||
const res = await fetch('/api/vue/stats');
|
||||
if (res.ok) {
|
||||
// 服务端不需要授权,保持登录状态
|
||||
return;
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
<Group title="功能">
|
||||
<Field label="thinking.enabled" desc="最高优先级。跟随客户端 = 不干预(推荐);强制关闭 = 即使客户端请求也不启用;强制开启 = 即使客户端未请求也注入">
|
||||
<SegSelect
|
||||
:modelValue="draft.thinking === null ? 'auto' : draft.thinking.enabled ? 'on' : 'off'"
|
||||
@update:modelValue="v => draft.thinking = v === 'auto' ? null : { enabled: v === 'on' }"
|
||||
:modelValue="draft!.thinking === null ? 'auto' : draft!.thinking.enabled ? 'on' : 'off'"
|
||||
@update:modelValue="v => draft!.thinking = v === 'auto' ? null : { enabled: v === 'on' }"
|
||||
:options="[
|
||||
{ value: 'auto', label: '跟随客户端' },
|
||||
{ value: 'off', label: '强制关闭' },
|
||||
|
||||
@@ -41,7 +41,7 @@ async function submit() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const res = await fetch('/api/stats', { headers: { Authorization: `Bearer ${t}` } });
|
||||
const res = await fetch('/api/vue/stats', { headers: { Authorization: `Bearer ${t}` } });
|
||||
if (res.status === 401) {
|
||||
auth.clearToken();
|
||||
error.value = 'Token 无效,请检查后重试';
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
/>
|
||||
<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">
|
||||
@@ -19,6 +26,7 @@
|
||||
:key="t.value"
|
||||
class="tb"
|
||||
:class="{ a: logsStore.timeFilter === t.value }"
|
||||
:title="t.title"
|
||||
@click="logsStore.timeFilter = t.value"
|
||||
>{{ t.label }}</button>
|
||||
</div>
|
||||
@@ -28,10 +36,13 @@
|
||||
v-for="f in statusTabs"
|
||||
:key="f.value"
|
||||
class="fb"
|
||||
:class="{ a: logsStore.statusFilter === f.value }"
|
||||
:class="[{ a: logsStore.statusFilter === f.value }, f.value]"
|
||||
:title="f.title"
|
||||
@click="logsStore.statusFilter = f.value"
|
||||
>
|
||||
{{ f.label }}<span class="fc">{{ counts[f.value] }}</span>
|
||||
<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>
|
||||
<!-- 请求列表 -->
|
||||
@@ -52,26 +63,26 @@
|
||||
<span class="ri-title-text">{{ req.title || shortModel(req.model) }}</span>
|
||||
</div>
|
||||
<div class="ri-time">
|
||||
<span v-if="req.endTime" class="dur"> 耗时 {{ fmtMs(req.endTime - req.startTime) }}</span>
|
||||
<span v-if="req.ttft" class="ttft"> ⚡️{{ fmtMs(req.ttft) }}</span>
|
||||
<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">{{ req.requestId.slice(0, 8) }}</span>
|
||||
<span class="rfmt" :class="req.apiFormat">{{ req.apiFormat }}</span>
|
||||
<span v-if="req.responseChars" class="rchars">{{ fmtN(req.responseChars) }} chars</span>
|
||||
<span v-if="req.inputTokens" class="rchars">↑{{ fmtN(req.inputTokens) }}↓{{ fmtN(req.outputTokens ?? 0) }} tok</span>
|
||||
<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">Stream</span>
|
||||
<span v-if="req.toolCount > 0" class="bg bg-tool">T:{{ req.toolCount }}</span>
|
||||
<span v-if="req.toolCallsDetected > 0" class="bg bg-call">C:{{ req.toolCallsDetected }}</span>
|
||||
<span v-if="req.retryCount > 0" class="bg bg-retry">R:{{ req.retryCount }}</span>
|
||||
<span v-if="req.continuationCount > 0" class="bg bg-cont">+{{ req.continuationCount }}</span>
|
||||
<span v-if="req.thinkingChars > 0" class="bg bg-think">🤔 {{ fmtN(req.thinkingChars) }} chars</span>
|
||||
<span v-if="req.status === 'degraded'" class="bg bg-deg">DEGRADED</span>
|
||||
<span v-if="req.status === 'error'" class="bg bg-err">ERR</span>
|
||||
<span v-if="req.status === 'intercepted'" class="bg bg-int">INTERCEPT</span>
|
||||
<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>
|
||||
@@ -87,7 +98,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useLogsStore } from '../stores/logs';
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
@@ -125,21 +136,29 @@ 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: '全部' },
|
||||
{ value: 'today' as const, label: '今天' },
|
||||
{ value: '2d' as const, label: '两天' },
|
||||
{ value: '7d' as const, label: '一周' },
|
||||
{ value: '30d' as const, label: '一月' },
|
||||
{ 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, label: '全部' },
|
||||
{ value: 'success' as const, label: '成功' },
|
||||
{ value: 'degraded' as const, label: '降级' },
|
||||
{ value: 'error' as const, label: '错误' },
|
||||
{ value: 'processing' as const, label: '处理中' },
|
||||
{ value: 'intercepted' as const, label: '中断' },
|
||||
{ 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);
|
||||
@@ -192,6 +211,14 @@ function selectReq(id: string) {
|
||||
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>
|
||||
@@ -200,11 +227,12 @@ function selectReq(id: string) {
|
||||
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); }
|
||||
.sw { position: relative; }
|
||||
.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;
|
||||
@@ -221,6 +249,13 @@ function selectReq(id: string) {
|
||||
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 {
|
||||
@@ -234,15 +269,22 @@ function selectReq(id: string) {
|
||||
|
||||
.fbar { padding: 5px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 3px; flex-wrap: wrap; }
|
||||
.fb {
|
||||
padding: 3px 9px; font-size: 10px; font-weight: 500;
|
||||
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: 3px;
|
||||
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; }
|
||||
.fc { font-size: 9px; font-weight: 700; padding: 0 4px; border-radius: 8px; background: rgba(255,255,255,.2); }
|
||||
.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; }
|
||||
@@ -310,7 +352,7 @@ function selectReq(id: string) {
|
||||
}
|
||||
.rfmt.anthropic { background: #7c3aed22; color: #a78bfa; }
|
||||
.rfmt.openai { background: #05966922; color: #34d399; }
|
||||
.rfmt.responses { background: #0ea5e9 22; color: #38bdf8; }
|
||||
.rfmt.responses { background: #0ea5e922; color: #38bdf8; }
|
||||
.rchars { font-size: 10px; font-family: var(--mono); color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
/* badges 行 */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { onUnmounted } from 'vue';
|
||||
import { createSSEConnection } from '../api';
|
||||
import { useLogsStore } from '../stores/logs';
|
||||
import { useStatsStore } from '../stores/stats';
|
||||
import type { LogEntry, RequestSummary, Stats } from '../types';
|
||||
import type { LogEntry, RequestSummary } from '../types';
|
||||
|
||||
export function useSSE(onConnected?: (connected: boolean) => void) {
|
||||
const logsStore = useLogsStore();
|
||||
@@ -18,7 +18,7 @@ export function useSSE(onConnected?: (connected: boolean) => void) {
|
||||
} else if (event === 'summary') {
|
||||
logsStore.upsertRequest(data as RequestSummary);
|
||||
} else if (event === 'stats') {
|
||||
statsStore.update(data as Stats);
|
||||
statsStore.load();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue';
|
||||
import type { LogEntry, RequestSummary, Payload } from '../types';
|
||||
import { fetchRequests, fetchLogs, fetchPayload, clearLogs, fetchMoreRequests } from '../api';
|
||||
import type { RequestsFilter } from '../api';
|
||||
import { useStatsStore } from './stats';
|
||||
|
||||
export const useLogsStore = defineStore('logs', () => {
|
||||
const reqs = ref<RequestSummary[]>([]);
|
||||
@@ -17,7 +18,9 @@ export const useLogsStore = defineStore('logs', () => {
|
||||
|
||||
const search = ref('');
|
||||
const statusFilter = ref<'all' | 'success' | 'degraded' | 'error' | 'processing' | 'intercepted'>('all');
|
||||
const timeFilter = ref<'all' | 'today' | '2d' | '7d' | '30d'>('all');
|
||||
const timeFilter = ref<'all' | '1h' | '6h' | 'today' | '2d' | '7d' | '30d'>('all');
|
||||
const autoFollow = ref(false);
|
||||
const autoFollowTriggered = ref(false);
|
||||
|
||||
function getTimeCutoff(): number {
|
||||
if (timeFilter.value === 'all') return 0;
|
||||
@@ -25,7 +28,7 @@ export const useLogsStore = defineStore('logs', () => {
|
||||
if (timeFilter.value === 'today') {
|
||||
const d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime();
|
||||
}
|
||||
const map: Record<string, number> = { '2d': 2, '7d': 7, '30d': 30 };
|
||||
const map: Record<string, number> = { '1h': 1/24, '6h': 6/24, '2d': 2, '7d': 7, '30d': 30 };
|
||||
return now - (map[timeFilter.value] ?? 0) * 86400000;
|
||||
}
|
||||
|
||||
@@ -90,6 +93,8 @@ export const useLogsStore = defineStore('logs', () => {
|
||||
// 状态/时间过滤:点击立即触发
|
||||
watch([statusFilter, timeFilter], () => {
|
||||
resetAndLoad();
|
||||
const cutoff = getTimeCutoff();
|
||||
useStatsStore().load(cutoff > 0 ? cutoff : undefined);
|
||||
});
|
||||
|
||||
// 搜索框:400ms 防抖
|
||||
@@ -137,13 +142,18 @@ export const useLogsStore = defineStore('logs', () => {
|
||||
if (oldStatus && statusCounts.value[oldStatus]) statusCounts.value[oldStatus]--;
|
||||
if (summary.status) statusCounts.value[summary.status] = (statusCounts.value[summary.status] ?? 0) + 1;
|
||||
}
|
||||
reqs.value[idx] = summary;
|
||||
Object.assign(reqs.value[idx], summary);
|
||||
} else {
|
||||
// 新请求:递增计数
|
||||
statusCounts.value.all = (statusCounts.value.all ?? 0) + 1;
|
||||
if (summary.status) statusCounts.value[summary.status] = (statusCounts.value[summary.status] ?? 0) + 1;
|
||||
total.value++;
|
||||
reqs.value.unshift(summary);
|
||||
// 自动跟随:已有选中记录时自动切换到最新
|
||||
if (autoFollow.value && curRequestId.value !== null) {
|
||||
autoFollowTriggered.value = true;
|
||||
selectRequest(summary.requestId).finally(() => { autoFollowTriggered.value = false; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +177,7 @@ export const useLogsStore = defineStore('logs', () => {
|
||||
|
||||
return {
|
||||
reqs, curLogs, globalLogs, displayLogs, curRequestId, payload,
|
||||
search, statusFilter, timeFilter, filteredReqs,
|
||||
search, statusFilter, timeFilter, autoFollow, autoFollowTriggered, filteredReqs,
|
||||
hasMore, loadingMore, total, statusCounts,
|
||||
loadRequests, loadMoreRequests, selectRequest, deselect, addLog, upsertRequest, clear, resetState,
|
||||
};
|
||||
|
||||
@@ -13,8 +13,8 @@ export const useStatsStore = defineStore('stats', () => {
|
||||
avgTTFT: 0,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try { stats.value = await fetchStats(); } catch { /* ignore */ }
|
||||
async function load(since?: number) {
|
||||
try { stats.value = await fetchStats(since); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function update(data: Stats) {
|
||||
|
||||
Reference in New Issue
Block a user