Spaces:
Sleeping
Sleeping
| <template> | |
| <div class="min-h-screen overflow-x-hidden bg-card/70 text-foreground backdrop-blur"> | |
| <div class="mx-auto w-full max-w-6xl min-w-0 px-4 py-8"> | |
| <section class="rounded-3xl border border-border bg-card p-6"> | |
| <div class="flex flex-wrap items-start justify-between gap-4"> | |
| <div class="flex items-center gap-3"> | |
| <img :src="logoUrl" alt="Gemini Business2API" class="h-8 w-8 object-contain" /> | |
| <div> | |
| <p class="text-base font-semibold text-foreground">公开日志</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2 text-xs text-muted-foreground"> | |
| <span>自动刷新:3s</span> | |
| </div> | |
| </div> | |
| <div | |
| class="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/40 px-4 py-3" | |
| > | |
| <div class="text-xs text-muted-foreground"> | |
| 展示最近 <span class="font-semibold text-foreground">{{ limit }}</span> 条会话日志 | |
| </div> | |
| <a | |
| v-if="chatUrl" | |
| :href="chatUrl" | |
| target="_blank" | |
| class="text-xs font-medium text-emerald-600 transition hover:text-emerald-500" | |
| > | |
| 开始对话 | |
| </a> | |
| <span v-else class="text-xs text-muted-foreground">开始对话</span> | |
| </div> | |
| <div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4"> | |
| <div | |
| v-for="card in statCards" | |
| :key="card.label" | |
| class="rounded-2xl border border-border bg-card px-4 py-3 text-center" | |
| > | |
| <div class="text-[11px] text-muted-foreground">{{ card.label }}</div> | |
| <div class="mt-1 text-lg font-semibold" :style="{ color: card.color || undefined }"> | |
| {{ card.value }} | |
| </div> | |
| </div> | |
| </div> | |
| <div | |
| v-if="errorMessage" | |
| class="mt-4 rounded-2xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive" | |
| > | |
| {{ errorMessage }} | |
| </div> | |
| <div | |
| v-if="logs.length === 0 && !errorMessage" | |
| class="mt-4 rounded-2xl border border-border bg-secondary/30 px-4 py-6 text-center text-sm text-muted-foreground" | |
| > | |
| 暂无日志 | |
| </div> | |
| <div v-else-if="logs.length > 0" class="mt-4 max-h-[60vh] space-y-3 overflow-y-auto pr-1 scrollbar-slim"> | |
| <div v-for="log in visibleLogs" :key="log.request_id" class="rounded-2xl border border-border bg-card"> | |
| <button | |
| type="button" | |
| class="flex w-full flex-wrap items-center gap-2 rounded-2xl bg-secondary/40 px-4 py-3 text-left text-xs transition hover:bg-secondary/60" | |
| @click="toggleGroup(log.request_id)" | |
| > | |
| <span :class="statusBadgeClass(log.status)">{{ statusLabel(log.status) }}</span> | |
| <span class="text-muted-foreground">req_{{ log.request_id }}</span> | |
| <span class="text-muted-foreground">{{ log.events.length }} 条事件</span> | |
| <span | |
| class="ml-auto text-muted-foreground transition-transform" | |
| :class="{ 'rotate-90': !isCollapsed(log.request_id) }" | |
| > | |
| ▸ | |
| </span> | |
| </button> | |
| <div v-if="!isCollapsed(log.request_id)" class="space-y-2 px-4 py-3"> | |
| <div | |
| v-for="event in log.events" | |
| :key="`${log.request_id}-${event.time}-${event.type}`" | |
| class="cv-auto flex flex-wrap items-center gap-3 rounded-xl border border-border bg-card px-3 py-2 text-xs" | |
| > | |
| <div class="text-muted-foreground">{{ event.time }}</div> | |
| <span :class="eventBadgeClass(event)">{{ eventLabel(event) }}</span> | |
| <div class="flex-1 text-foreground">{{ event.content }}</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| </div> | |
| </template> | |
| <script setup lang="ts"> | |
| import { computed, onBeforeUnmount, onMounted, ref } from 'vue' | |
| import { publicDisplayApi, publicLogsApi, publicStatsApi } from '@/api' | |
| import type { | |
| PublicDisplay, | |
| PublicLogEvent, | |
| PublicLogGroup, | |
| PublicLogStatus, | |
| PublicStats, | |
| } from '@/types/api' | |
| const logs = ref<PublicLogGroup[]>([]) | |
| const stats = ref<PublicStats | null>(null) | |
| const display = ref<PublicDisplay | null>(null) | |
| const errorMessage = ref('') | |
| const lastUpdated = ref('--:--') | |
| const collapsedState = ref<Record<string, boolean>>({}) | |
| const limit = 1000 | |
| const renderLimit = 1000 | |
| const refreshIntervalMs = 3000 | |
| let timer: number | undefined | |
| let isFetching = false | |
| const logoUrl = computed(() => { | |
| const url = display.value?.logo_url?.trim() | |
| return url || '/logo.svg' | |
| }) | |
| const chatUrl = computed(() => display.value?.chat_url?.trim() || '') | |
| const totalLogs = computed(() => logs.value.length) | |
| const successLogs = computed(() => logs.value.filter(log => log.status === 'success').length) | |
| const errorLogs = computed(() => logs.value.filter(log => log.status === 'error').length) | |
| const visibleLogs = computed(() => { | |
| if (logs.value.length > renderLimit) { | |
| return logs.value.slice(-renderLimit) | |
| } | |
| return logs.value | |
| }) | |
| const avgResponseTime = computed(() => { | |
| let total = 0 | |
| let count = 0 | |
| logs.value.forEach(log => { | |
| if (log.status !== 'success') return | |
| log.events.forEach(event => { | |
| if (event.type !== 'complete') return | |
| const match = event.content.match(/([0-9]+(?:\.[0-9]+)?)\s*s/) | |
| if (match) { | |
| total += Number(match[1]) | |
| count += 1 | |
| } | |
| }) | |
| }) | |
| if (count === 0) return '-' | |
| return `${(total / count).toFixed(1)}s` | |
| }) | |
| const successRate = computed(() => { | |
| const completed = successLogs.value + errorLogs.value | |
| if (completed === 0) return '-' | |
| return `${((successLogs.value / completed) * 100).toFixed(1)}%` | |
| }) | |
| const statCards = computed(() => [ | |
| { label: '总访客', value: stats.value?.total_visitors ?? 0 }, | |
| { | |
| label: '每分钟请求', | |
| value: stats.value?.requests_per_minute ?? 0, | |
| color: stats.value?.load_color, | |
| }, | |
| { label: '平均响应', value: avgResponseTime.value }, | |
| { label: '成功率', value: successRate.value, color: '#10b981' }, | |
| { label: '对话次数', value: totalLogs.value }, | |
| { label: '成功', value: successLogs.value, color: '#10b981' }, | |
| { label: '失败', value: errorLogs.value, color: '#ef4444' }, | |
| { label: '更新时间', value: lastUpdated.value, color: '#6b7280' }, | |
| ]) | |
| const statusLabel = (status: PublicLogStatus) => { | |
| if (status === 'success') return '成功' | |
| if (status === 'error') return '失败' | |
| if (status === 'timeout') return '超时' | |
| return '进行中' | |
| } | |
| const statusBadgeClass = (status: PublicLogStatus) => { | |
| const base = 'rounded-md px-2 py-0.5 text-[11px] font-semibold' | |
| if (status === 'success') return `${base} bg-emerald-100 text-emerald-700` | |
| if (status === 'error') return `${base} bg-rose-100 text-rose-700` | |
| if (status === 'timeout') return `${base} bg-amber-100 text-amber-700` | |
| return `${base} bg-amber-100 text-amber-700` | |
| } | |
| const eventLabel = (event: PublicLogEvent) => { | |
| if (event.type === 'start') return '开始对话' | |
| if (event.type === 'select') return '选择' | |
| if (event.type === 'retry') return '重试' | |
| if (event.type === 'switch') return '切换' | |
| if (event.type === 'complete') { | |
| if (event.status === 'success') return '完成' | |
| if (event.status === 'error') return '失败' | |
| if (event.status === 'timeout') return '超时' | |
| return '完成' | |
| } | |
| return '事件' | |
| } | |
| const eventBadgeClass = (event: PublicLogEvent) => { | |
| const base = 'rounded-md px-2 py-0.5 text-[11px] font-semibold' | |
| if (event.type === 'start') return `${base} bg-blue-100 text-blue-700` | |
| if (event.type === 'select') return `${base} bg-violet-100 text-violet-700` | |
| if (event.type === 'retry') return `${base} bg-amber-100 text-amber-700` | |
| if (event.type === 'switch') return `${base} bg-cyan-100 text-cyan-700` | |
| if (event.type === 'complete') { | |
| if (event.status === 'success') return `${base} bg-emerald-100 text-emerald-700` | |
| if (event.status === 'error') return `${base} bg-rose-100 text-rose-700` | |
| if (event.status === 'timeout') return `${base} bg-amber-100 text-amber-700` | |
| } | |
| return `${base} bg-slate-100 text-slate-600` | |
| } | |
| const loadCollapseState = () => { | |
| try { | |
| const saved = localStorage.getItem('public-log-fold-state') | |
| if (saved) collapsedState.value = JSON.parse(saved) | |
| } catch { | |
| collapsedState.value = {} | |
| } | |
| } | |
| const saveCollapseState = () => { | |
| localStorage.setItem('public-log-fold-state', JSON.stringify(collapsedState.value)) | |
| } | |
| const isCollapsed = (requestId: string) => collapsedState.value[requestId] === true | |
| const toggleGroup = (requestId: string) => { | |
| collapsedState.value[requestId] = !isCollapsed(requestId) | |
| saveCollapseState() | |
| } | |
| const fetchData = async () => { | |
| if (isFetching) return | |
| isFetching = true | |
| errorMessage.value = '' | |
| try { | |
| const [logsResponse, statsResponse] = await Promise.all([ | |
| publicLogsApi.list({ limit }), | |
| publicStatsApi.overview(), | |
| ]) | |
| logs.value = logsResponse.logs | |
| stats.value = statsResponse | |
| lastUpdated.value = new Date().toLocaleTimeString('zh-CN', { | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '2-digit', | |
| }) | |
| } catch (error: any) { | |
| errorMessage.value = error.message || '日志加载失败' | |
| } finally { | |
| isFetching = false | |
| } | |
| } | |
| const fetchDisplay = async () => { | |
| try { | |
| display.value = await publicDisplayApi.overview() | |
| } catch { | |
| display.value = null | |
| } | |
| } | |
| const stopAutoRefresh = () => { | |
| if (timer) { | |
| window.clearTimeout(timer) | |
| timer = undefined | |
| } | |
| } | |
| const scheduleAutoRefresh = () => { | |
| if (document.hidden) return | |
| timer = window.setTimeout(async () => { | |
| await fetchData() | |
| scheduleAutoRefresh() | |
| }, refreshIntervalMs) | |
| } | |
| const startAutoRefresh = () => { | |
| stopAutoRefresh() | |
| scheduleAutoRefresh() | |
| } | |
| const handleVisibilityChange = () => { | |
| if (document.hidden) { | |
| stopAutoRefresh() | |
| } else { | |
| startAutoRefresh() | |
| } | |
| } | |
| onMounted(() => { | |
| loadCollapseState() | |
| fetchDisplay() | |
| fetchData() | |
| startAutoRefresh() | |
| document.addEventListener('visibilitychange', handleVisibilityChange) | |
| }) | |
| onBeforeUnmount(() => { | |
| stopAutoRefresh() | |
| document.removeEventListener('visibilitychange', handleVisibilityChange) | |
| }) | |
| </script> | |