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:
huangzhenting
2026-03-23 17:35:13 +08:00
parent 2933df2697
commit 717af46606
13 changed files with 181 additions and 64 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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> {

View File

@@ -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;

View File

@@ -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: '强制关闭' },

View File

@@ -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 无效,请检查后重试';

View File

@@ -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 行 */

View File

@@ -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();
}
});

View File

@@ -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,
};

View File

@@ -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) {