添加站点耗时统计信息展示

This commit is contained in:
jxxghp
2025-08-02 14:20:17 +08:00
parent d57e9a397c
commit ae3eb36183
6 changed files with 580 additions and 39 deletions

View File

@@ -0,0 +1,479 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import api from '@/api'
import type { Site, SiteStatistic } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
sites: {
type: Array as PropType<Site[]>,
default: () => [],
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue'])
// 站点统计数据
const siteStats = ref<SiteStatistic[]>([])
// 是否加载中
const loading = ref(false)
// 当前选中的站点
const selectedSite = ref<Site | null>(null)
// 耗时记录详情弹窗
const detailDialog = ref(false)
// 获取站点统计数据
async function fetchSiteStats() {
try {
loading.value = true
const response = await api.get('site/statistic')
siteStats.value = Array.isArray(response) ? response : response.data || []
loading.value = false
} catch (error) {
console.error('Failed to fetch site statistics:', error)
loading.value = false
}
}
// 根据站点域名获取统计数据
function getSiteStats(domain: string): SiteStatistic | undefined {
return siteStats.value.find(stat => stat.domain === domain)
}
// 获取站点连接状态
function getConnectionStatus(stats: SiteStatistic | undefined): string {
if (!stats || Object.keys(stats).length === 0) {
return 'unknown'
}
if (stats.lst_state === 1) {
return 'failed'
} else if (stats.lst_state === 0) {
if (!stats.seconds) return 'unknown'
if (stats.seconds >= 5) return 'slow'
return 'connected'
}
return 'unknown'
}
// 获取状态颜色
function getStatusColor(status: string): string {
switch (status) {
case 'connected':
return 'success'
case 'slow':
return 'warning'
case 'failed':
return 'error'
default:
return 'secondary'
}
}
// 获取状态图标
function getStatusIcon(status: string): string {
switch (status) {
case 'connected':
return 'mdi-wifi'
case 'slow':
return 'mdi-wifi-strength-2'
case 'failed':
return 'mdi-wifi-off'
default:
return 'mdi-help-circle'
}
}
// 获取状态文本
function getStatusText(status: string): string {
switch (status) {
case 'connected':
return t('site.connectionNormal')
case 'slow':
return t('site.connectionSlow')
case 'failed':
return t('site.connectionFailed')
default:
return t('site.connectionUnknown')
}
}
// 获取耗时颜色
function getTimeColor(seconds: number | undefined): string {
if (!seconds) return 'secondary'
if (seconds < 2) return 'success'
if (seconds < 5) return 'warning'
return 'error'
}
// 解析耗时记录
function parseTimeRecords(note: any): Array<{ time: string; duration: number }> {
if (!note) return []
try {
// note可能是字符串或对象如果是字符串则解析
const records = typeof note === 'string' ? JSON.parse(note) : note
if (typeof records === 'object' && records !== null) {
const result = Object.entries(records)
.map(([time, duration]) => ({
time,
duration: Number(duration) || 0,
}))
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
.slice(0, 10) // 只显示最近10条记录
return result
}
} catch (error) {
console.error('Failed to parse time records:', error)
}
return []
}
// 查看详情
function viewDetail(site: Site) {
selectedSite.value = site
detailDialog.value = true
}
// 关闭弹窗
function closeDialog() {
emit('update:modelValue', false)
}
// 计算属性:按平均耗时排序的站点列表
const sortedSites = computed(() => {
return props.sites
.map(site => {
const stats = getSiteStats(site.domain)
return {
site,
stats,
status: getConnectionStatus(stats),
avgTime: stats?.seconds || 0,
}
})
.sort((a, b) => {
// 先按状态排序connected > slow > failed > unknown
const statusOrder = { connected: 0, slow: 1, failed: 2, unknown: 3 }
const statusDiff =
statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder]
if (statusDiff !== 0) return statusDiff
// 再按平均耗时排序
return a.avgTime - b.avgTime
})
})
onMounted(() => {
fetchSiteStats()
})
</script>
<template>
<DialogWrapper max-width="50rem" :fullscreen="display.smAndDown.value">
<VCard>
<!-- 标题栏 -->
<VCardItem class="py-3">
<VDialogCloseBtn @click="closeDialog" />
<template #prepend>
<VIcon icon="mdi-chart-line" class="me-2" />
</template>
<VCardTitle>
{{ t('site.statistics') }}
</VCardTitle>
</VCardItem>
<VDivider />
<!-- 内容区域 -->
<VCardText class="pa-0">
<LoadingBanner v-if="loading" class="my-8" />
<div v-else class="site-statistics-content">
<!-- 统计概览 -->
<div class="statistics-overview pa-4">
<div class="d-flex flex-wrap gap-4">
<div class="stat-card">
<div class="stat-number">{{ siteStats.length }}</div>
<div class="stat-label">{{ t('site.totalSites') }}</div>
</div>
<div class="stat-card">
<div class="stat-number success--text">
{{ siteStats.filter(s => s.lst_state === 0).length }}
</div>
<div class="stat-label">{{ t('site.normalSites') }}</div>
</div>
<div class="stat-card">
<div class="stat-number warning--text">
{{ siteStats.filter(s => s.lst_state === 0 && s.seconds && s.seconds >= 5).length }}
</div>
<div class="stat-label">{{ t('site.slowSites') }}</div>
</div>
<div class="stat-card">
<div class="stat-number error--text">
{{ siteStats.filter(s => s.lst_state === 1).length }}
</div>
<div class="stat-label">{{ t('site.failedSites') }}</div>
</div>
</div>
</div>
<!-- 站点列表 -->
<div class="sites-list">
<div
v-for="item in sortedSites"
:key="item.site.id"
class="site-item pa-4 border-b"
:class="`border-${getStatusColor(item.status)}`"
>
<div class="d-flex align-center justify-space-between">
<!-- 左侧站点信息 -->
<div class="d-flex align-center flex-1 min-w-0">
<!-- 状态指示器 -->
<div class="status-indicator me-3" :class="getStatusColor(item.status)">
<VIcon :icon="getStatusIcon(item.status)" size="20" />
</div>
<!-- 站点名称和状态 -->
<div class="flex-1 min-w-0">
<div class="d-flex align-center">
<h4 class="text-h6 mb-1 truncate">{{ item.site.name }}</h4>
<VChip :color="getStatusColor(item.status)" size="small" class="ml-2" variant="tonal">
{{ getStatusText(item.status) }}
</VChip>
</div>
<div class="text-caption text-medium-emphasis">{{ item.site.domain }}</div>
</div>
</div>
<!-- 右侧统计信息 -->
<div class="d-flex align-center gap-4">
<!-- 平均耗时 -->
<div class="text-center">
<div class="text-h6 font-weight-bold" :class="`text-${getTimeColor(item.stats?.seconds)}`">
{{ item.stats?.seconds || '-' }}s
</div>
<div class="text-caption text-medium-emphasis">{{ t('site.averageTime') }}</div>
</div>
<!-- 成功率 -->
<div class="text-center">
<div class="text-h6 font-weight-bold">
{{
item.stats?.success && item.stats?.fail
? Math.round((item.stats.success / (item.stats.success + item.stats.fail)) * 100)
: '-'
}}%
</div>
<div class="text-caption text-medium-emphasis">{{ t('site.successRate') }}</div>
</div>
<!-- 详情按钮 -->
<VBtn icon variant="text" size="small" @click="viewDetail(item.site)">
<VIcon icon="mdi-information-outline" />
</VBtn>
</div>
</div>
</div>
</div>
</div>
</VCardText>
</VCard>
<!-- 详情弹窗 -->
<DialogWrapper v-model="detailDialog" :max-width="display.mdAndUp.value ? 600 : '95%'">
<VCard v-if="selectedSite">
<VCardItem class="py-3">
<template #prepend>
<VIcon icon="mdi-information-outline" class="me-2" />
</template>
<VCardTitle> {{ selectedSite.name }} - {{ t('site.timeRecords') }} </VCardTitle>
<VDialogCloseBtn @click="detailDialog = false" />
</VCardItem>
<VDivider />
<VCardText class="pa-4">
<div v-if="getSiteStats(selectedSite.domain)">
<div class="mb-4">
<h5 class="text-h6 mb-2">{{ t('site.statistics') }}</h5>
<div class="d-flex flex-wrap gap-4">
<div class="stat-item">
<span class="stat-label">{{ t('site.successCount') }}:</span>
<span class="stat-value success--text">
{{ getSiteStats(selectedSite.domain)?.success || 0 }}
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('site.failCount') }}:</span>
<span class="stat-value error--text">
{{ getSiteStats(selectedSite.domain)?.fail || 0 }}
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('site.averageTime') }}:</span>
<span class="stat-value" :class="`text-${getTimeColor(getSiteStats(selectedSite.domain)?.seconds)}`">
{{ getSiteStats(selectedSite.domain)?.seconds || '-' }}s
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('site.lastAccess') }}:</span>
<span class="stat-value">
{{ getSiteStats(selectedSite.domain)?.lst_mod_date || '-' }}
</span>
</div>
</div>
</div>
<div>
<h5 class="text-h6 mb-2">{{ t('site.recentTimeRecords') }}</h5>
<div class="time-records">
<div
v-for="(record, index) in parseTimeRecords(getSiteStats(selectedSite.domain)?.note)"
:key="index"
class="time-record-item pa-3 border rounded mb-2"
:class="`border-${getTimeColor(record.duration)}`"
>
<div class="d-flex justify-space-between align-center">
<div>
<div class="text-body-2 font-weight-medium">{{ record.time }}</div>
<div class="text-caption text-medium-emphasis">{{ t('site.accessTime') }}</div>
</div>
<div class="text-end">
<div class="text-h6 font-weight-bold" :class="`text-${getTimeColor(record.duration)}`">
{{ record.duration }}s
</div>
<div class="text-caption text-medium-emphasis">{{ t('site.responseTime') }}</div>
</div>
</div>
</div>
<div
v-if="parseTimeRecords(getSiteStats(selectedSite.domain)?.note).length === 0"
class="text-center pa-4"
>
<VIcon icon="mdi-information-outline" size="48" color="secondary" class="mb-2" />
<div class="text-body-1 text-medium-emphasis">{{ t('site.noTimeRecords') }}</div>
</div>
</div>
</div>
</div>
</VCardText>
</VCard>
</DialogWrapper>
</DialogWrapper>
</template>
<style scoped>
.site-statistics-content {
max-block-size: 70vh;
overflow-y: auto;
}
.statistics-overview {
background: linear-gradient(135deg, var(--v-theme-surface) 0%, var(--v-theme-surface-variant) 100%);
border-block-end: 1px solid var(--v-border-color);
}
.stat-card {
padding: 16px;
border: 1px solid var(--v-border-color);
border-radius: 8px;
background: var(--v-theme-surface);
min-inline-size: 100px;
text-align: center;
}
.stat-number {
font-size: 24px;
font-weight: bold;
line-height: 1;
margin-block-end: 4px;
}
.stat-label {
color: var(--v-theme-on-surface-variant);
font-size: 12px;
}
.sites-list {
background: var(--v-theme-surface);
}
.site-item {
transition: background-color 0.2s ease;
}
.site-item:hover {
background: var(--v-theme-surface-variant);
}
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--v-theme-surface-variant);
block-size: 40px;
inline-size: 40px;
}
.status-indicator.success {
background: rgba(var(--v-theme-success), 0.1);
color: rgb(var(--v-theme-success));
}
.status-indicator.warning {
background: rgba(var(--v-theme-warning), 0.1);
color: rgb(var(--v-theme-warning));
}
.status-indicator.error {
background: rgba(var(--v-theme-error), 0.1);
color: rgb(var(--v-theme-error));
}
.status-indicator.secondary {
background: rgba(var(--v-theme-secondary), 0.1);
color: rgb(var(--v-theme-secondary));
}
.stat-item {
display: flex;
align-items: center;
gap: 8px;
}
.stat-item .stat-label {
color: var(--v-theme-on-surface-variant);
font-weight: 500;
}
.stat-value {
font-weight: bold;
}
.time-records {
max-block-size: 300px;
overflow-y: auto;
}
.time-record-item {
transition: all 0.2s ease;
}
.time-record-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 10%);
transform: translateY(-1px);
}
</style>

View File

@@ -290,8 +290,8 @@ onBeforeMount(() => {
<DialogWrapper scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle
>{{ t('dialog.siteUserData.title') }} - {{ props.site?.name }}
<VCardTitle>
{{ t('dialog.siteUserData.title') }} - {{ props.site?.name }}
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh" /></IconBtn>
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />

View File

@@ -1053,6 +1053,21 @@ export default {
deleteSite: 'Delete Site',
updateCookie: 'Update Cookie',
viewUserData: 'View User Data',
statistics: 'Statistics',
totalSites: 'Total Sites',
normalSites: 'Normal Sites',
slowSites: 'Slow Sites',
failedSites: 'Failed Sites',
averageTime: 'Average Time',
successRate: 'Success Rate',
successCount: 'Success Count',
failCount: 'Fail Count',
lastAccess: 'Last Access',
timeRecords: 'Time Records',
recentTimeRecords: 'Recent Time Records',
accessTime: 'Access Time',
responseTime: 'Response Time',
noTimeRecords: 'No Time Records',
},
message: {
loadMore: 'Load More',

View File

@@ -1049,6 +1049,21 @@ export default {
deleteSite: '删除站点',
updateCookie: '更新Cookie',
viewUserData: '查看用户数据',
statistics: '统计信息',
totalSites: '总站点数',
normalSites: '正常站点',
slowSites: '缓慢站点',
failedSites: '失败站点',
averageTime: '平均耗时',
successRate: '成功率',
successCount: '成功次数',
failCount: '失败次数',
lastAccess: '最后访问',
timeRecords: '耗时记录',
recentTimeRecords: '最近耗时记录',
accessTime: '访问时间',
responseTime: '响应时间',
noTimeRecords: '暂无耗时记录',
},
message: {
loadMore: '加载更多',

View File

@@ -1048,6 +1048,21 @@ export default {
deleteSite: '刪除站點',
updateCookie: '更新Cookie',
viewUserData: '查看用戶數據',
statistics: '統計信息',
totalSites: '總站點數',
normalSites: '正常站點',
slowSites: '緩慢站點',
failedSites: '失敗站點',
averageTime: '平均耗時',
successRate: '成功率',
successCount: '成功次數',
failCount: '失敗次數',
lastAccess: '最後訪問',
timeRecords: '耗時記錄',
recentTimeRecords: '最近耗時記錄',
accessTime: '訪問時間',
responseTime: '響應時間',
noTimeRecords: '暫無耗時記錄',
},
message: {
loadMore: '加載更多',

View File

@@ -5,6 +5,7 @@ import type { Site, SiteUserData } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
import SiteStatisticsDialog from '@/components/dialog/SiteStatisticsDialog.vue'
import { useDisplay } from 'vuetify'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
@@ -39,6 +40,9 @@ const loading = ref(false)
// 新增站点对话框
const siteAddDialog = ref(false)
// 统计信息对话框
const siteStatsDialog = ref(false)
// 筛选相关
const filterMenu = ref(false)
const filterOption = ref('all') // all, active, inactive, connected, slow, failed, unknown
@@ -235,44 +239,54 @@ useDynamicButton({
<!-- 页面标题和筛选按钮 -->
<div class="d-flex justify-space-between align-center mb-4">
<VPageContentTitle :title="t('navItems.siteManager')" class="mb-0" />
<!-- 筛选按钮 -->
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
<template #activator="{ props }">
<VBtn
v-bind="props"
:icon="display.smAndDown.value"
:variant="filterOption === 'all' ? 'text' : 'tonal'"
:color="currentFilter?.color"
>
<VIcon :icon="currentFilter?.icon || 'mdi-filter'" />
<span v-if="!display.smAndDown.value" class="ml-2">
{{ currentFilter?.label }}
</span>
<VIcon v-if="!display.smAndDown.value" icon="mdi-chevron-down" class="ml-1" />
</VBtn>
</template>
<!-- 筛选菜单 -->
<VCard min-width="200">
<VList class="px-2">
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
<VListItem
v-for="option in filterOptions"
:key="option.value"
:active="filterOption === option.value"
@click="selectFilter(option.value)"
<!-- 右侧按钮 -->
<div class="d-flex align-center gap-2">
<!-- 统计信息按钮 -->
<VBtn :icon="display.smAndDown.value" variant="text" color="info" @click="siteStatsDialog = true">
<VIcon icon="mdi-chart-line" />
<span v-if="!display.smAndDown.value" class="ml-2">
{{ t('site.statistics') }}
</span>
</VBtn>
<!-- 筛选按钮 -->
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
<template #activator="{ props }">
<VBtn
v-bind="props"
:icon="display.smAndDown.value"
:variant="filterOption === 'all' ? 'text' : 'tonal'"
:color="currentFilter?.color"
>
<template #prepend>
<VIcon :icon="option.icon" :color="option.color" />
</template>
<VListItemTitle>{{ option.label }}</VListItemTitle>
<template #append>
<VIcon v-if="filterOption === option.value" icon="mdi-check" color="primary" />
</template>
</VListItem>
</VList>
</VCard>
</VMenu>
<VIcon :icon="currentFilter?.icon || 'mdi-filter'" />
<span v-if="!display.smAndDown.value" class="ml-2">
{{ currentFilter?.label }}
</span>
<VIcon v-if="!display.smAndDown.value" icon="mdi-chevron-down" class="ml-1" />
</VBtn>
</template>
<!-- 筛选菜单 -->
<VCard min-width="200">
<VList class="px-2">
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
<VListItem
v-for="option in filterOptions"
:key="option.value"
:active="filterOption === option.value"
@click="selectFilter(option.value)"
>
<template #prepend>
<VIcon :icon="option.icon" :color="option.color" />
</template>
<VListItemTitle>{{ option.label }}</VListItemTitle>
<template #append>
<VIcon v-if="filterOption === option.value" icon="mdi-check" color="primary" />
</template>
</VListItem>
</VList>
</VCard>
</VMenu>
</div>
</div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
@@ -326,4 +340,7 @@ useDynamicButton({
@save="onSiteSave"
@close="siteAddDialog = false"
/>
<!-- 统计信息弹窗 -->
<SiteStatisticsDialog v-if="siteStatsDialog" v-model="siteStatsDialog" :sites="siteList" />
</template>