lijunke
deploy: clean start with hf metadata
18081cf
<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>