mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 17:08:33 +08:00
fix(mobile): 运维监控界面适配移动端
运维监控多处使用裸 table 且无移动端处理,小屏下内容被截断无法查看: - 系统日志、告警事件、告警规则、OpenAI Token 统计、请求明细五处宽表 参照 DataTable 的做法,在 < 768px 视口切换为卡片视图(useMediaQuery, 避免挂载隐藏表格) - 告警事件工具栏移动端纵向堆叠、筛选器自动换行 - 错误详情弹窗筛选栏 grid-cols-8 改为移动端两列折叠 桌面端布局保持不变;卡片视图完整支持深色模式,告警事件的无限滚动 在卡片视图下同样生效。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
@@ -12,6 +13,9 @@ import { formatDateTime } from '../utils/opsFormatters'
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 与 DataTable 一致:< 768px 切换为卡片视图,避免宽表在移动端被截断。
|
||||
const isDesktopViewport = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -356,13 +360,13 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertEvents.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Select :model-value="timeRange" :options="timeRangeOptions" class="w-[120px]" @change="timeRange = String($event || '24h')" />
|
||||
<Select :model-value="severity" :options="severityOptions" class="w-[88px]" @change="severity = String($event || '')" />
|
||||
<Select :model-value="status" :options="statusOptions" class="w-[110px]" @change="status = String($event || '')" />
|
||||
@@ -394,7 +398,50 @@ const empty = computed(() => events.value.length === 0 && !loading.value)
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="max-h-[600px] overflow-y-auto" @scroll="onScroll">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<div v-if="!isDesktopViewport" class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
<div
|
||||
v-for="row in events"
|
||||
:key="row.id"
|
||||
class="cursor-pointer space-y-2 p-4 hover:bg-gray-50 dark:hover:bg-dark-700/50"
|
||||
@click="openDetail(row)"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(row.severity || ''))">
|
||||
{{ row.severity || '-' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(row.status)">
|
||||
{{ formatStatusLabel(row.status) }}
|
||||
</span>
|
||||
<span class="ml-auto text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ formatDateTime(row.fired_at || row.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-semibold text-gray-900 dark:text-white">{{ row.title || '-' }}</div>
|
||||
<div v-if="row.description" class="line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<span><span class="font-mono">#{{ row.rule_id }}</span> · {{ formatDurationLabel(row) }}</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Icon
|
||||
v-if="row.email_sent"
|
||||
name="checkCircle"
|
||||
size="xs"
|
||||
class="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
name="ban"
|
||||
size="xs"
|
||||
class="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
{{ row.email_sent ? t('admin.ops.alertEvents.table.emailSent') : t('admin.ops.alertEvents.table.emailIgnored') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-400 dark:text-gray-500">{{ formatDimensionsSummary(row) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<table v-else class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
@@ -14,6 +15,9 @@ import { formatDateTime } from '../utils/opsFormatters'
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 与 DataTable 一致:< 768px 切换为卡片视图,避免宽表在移动端被截断。
|
||||
const isDesktopViewport = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
const loading = ref(false)
|
||||
const rules = ref<AlertRule[]>([])
|
||||
|
||||
@@ -388,7 +392,7 @@ function cancelDelete() {
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3 sm:gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertRules.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertRules.description') }}</p>
|
||||
@@ -421,7 +425,37 @@ function cancelDelete() {
|
||||
|
||||
<div v-else class="max-h-[520px] overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="max-h-[520px] overflow-y-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<div v-if="!isDesktopViewport" class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
<div v-for="row in sortedRules" :key="row.id" class="space-y-2 p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-bold text-gray-900 dark:text-white">{{ row.name }}</div>
|
||||
<div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs font-bold text-gray-700 dark:text-gray-200">{{ row.severity }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-700 dark:text-gray-200">
|
||||
<span class="font-mono">{{ row.metric_type }}</span>
|
||||
<span class="mx-1 text-gray-400">{{ row.operator }}</span>
|
||||
<span class="font-mono">{{ row.threshold }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-gray-700 dark:text-gray-200">
|
||||
{{ row.enabled ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-sm btn-secondary" @click="openEdit(row)">{{ t('common.edit') }}</button>
|
||||
<button class="btn btn-sm btn-danger" @click="requestDelete(row)">{{ t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.updated_at" class="text-[10px] text-gray-400">
|
||||
{{ formatDateTime(row.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table v-else class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -204,7 +204,7 @@ watch(
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex-shrink-0 border-b border-gray-200 pb-4 dark:border-dark-700">
|
||||
<div class="grid grid-cols-8 gap-2">
|
||||
<div class="grid grid-cols-2 gap-2 md:grid-cols-8">
|
||||
<div class="col-span-2 compact-select">
|
||||
<div class="relative group">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
@@ -21,6 +22,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 与 DataTable 一致:< 768px 切换为卡片视图,避免宽表在移动端被截断。
|
||||
const isDesktopViewport = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const response = ref<OpsOpenAITokenStatsResponse | null>(null)
|
||||
@@ -211,7 +215,38 @@ function onNextPage() {
|
||||
<div v-else class="space-y-3">
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="max-h-[420px] overflow-auto">
|
||||
<table class="min-w-full text-left text-xs md:text-sm">
|
||||
<div v-if="!isDesktopViewport" class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
<div v-for="row in items" :key="row.model" class="space-y-2 p-3">
|
||||
<div class="break-all text-xs font-medium text-gray-900 dark:text-gray-100">{{ row.model }}</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.ops.openaiTokenStats.table.requestCount') }}</span>
|
||||
<span class="text-gray-700 dark:text-gray-200">{{ formatInt(row.request_count) }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.ops.openaiTokenStats.table.avgTokensPerSec') }}</span>
|
||||
<span class="text-gray-700 dark:text-gray-200">{{ formatRate(row.avg_tokens_per_sec) }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.ops.openaiTokenStats.table.avgFirstTokenMs') }}</span>
|
||||
<span class="text-gray-700 dark:text-gray-200">{{ formatRate(row.avg_first_token_ms) }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.ops.openaiTokenStats.table.totalOutputTokens') }}</span>
|
||||
<span class="text-gray-700 dark:text-gray-200">{{ formatInt(row.total_output_tokens) }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.ops.openaiTokenStats.table.avgDurationMs') }}</span>
|
||||
<span class="text-gray-700 dark:text-gray-200">{{ formatInt(row.avg_duration_ms) }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.ops.openaiTokenStats.table.requestsWithFirstToken') }}</span>
|
||||
<span class="text-gray-700 dark:text-gray-200">{{ formatInt(row.requests_with_first_token) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table v-else class="min-w-full text-left text-xs md:text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-white dark:bg-dark-800">
|
||||
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.model') }}</th>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
@@ -34,6 +35,9 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 与 DataTable 一致:< 768px 切换为卡片视图,避免宽表在移动端被截断。
|
||||
const isDesktopViewport = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<OpsRequestDetail[]>([])
|
||||
const total = ref(0)
|
||||
@@ -189,7 +193,41 @@ const kindBadgeClass = (kind: string) => {
|
||||
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<div v-if="!isDesktopViewport" class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
<div v-for="(row, idx) in items" :key="idx" class="space-y-2 p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="kindBadgeClass(row.kind)">
|
||||
{{ row.kind === 'error' ? t('admin.ops.requestDetails.kind.error') : t('admin.ops.requestDetails.kind.success') }}
|
||||
</span>
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-200">{{ (row.platform || 'unknown').toUpperCase() }}</span>
|
||||
<span class="ml-auto text-[11px] text-gray-500 dark:text-gray-400">{{ formatDateTime(row.created_at) }}</span>
|
||||
</div>
|
||||
<div class="break-all text-xs text-gray-600 dark:text-gray-300">{{ row.model || '-' }}</div>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span>{{ typeof row.duration_ms === 'number' ? `${row.duration_ms} ms` : '-' }}</span>
|
||||
<span>{{ row.status_code ?? '-' }}</span>
|
||||
</div>
|
||||
<div v-if="row.request_id" class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-700 dark:text-gray-200" :title="row.request_id">
|
||||
{{ row.request_id }}
|
||||
</span>
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-gray-100 px-2 py-1 text-[10px] font-bold text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
@click="handleCopyRequestId(row.request_id)"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="row.kind === 'error' && row.error_id"
|
||||
class="w-full rounded-lg bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||
@click="openErrorDetail(row.error_id)"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.viewError') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table v-else class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { opsAPI, type OpsRuntimeLogConfig, type OpsSystemLog, type OpsSystemLogSinkHealth } from '@/api/admin/ops'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
@@ -9,6 +10,9 @@ import { useAppStore } from '@/stores'
|
||||
const appStore = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 与 DataTable 一致:< 768px 切换为卡片视图,避免宽表在移动端被截断。
|
||||
const isDesktopViewport = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
platformFilter?: string
|
||||
refreshToken?: number
|
||||
@@ -506,6 +510,22 @@ onMounted(async () => {
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div v-if="loading" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('admin.ops.systemLogs.empty') }}</div>
|
||||
<div v-else-if="!isDesktopViewport" class="divide-y divide-gray-100 dark:divide-dark-800">
|
||||
<div v-for="row in logs" :key="row.id" class="space-y-1.5 p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-semibold" :class="levelBadgeClass(row.level)">
|
||||
{{ row.level }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatTime(row.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="row.host" class="truncate text-xs text-gray-500 dark:text-gray-400" :title="row.host">
|
||||
{{ row.host }}
|
||||
</div>
|
||||
<div class="whitespace-normal break-all text-xs text-gray-700 dark:text-gray-300">
|
||||
{{ formatSystemLogDetail(row) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="overflow-auto">
|
||||
<table class="min-w-full table-fixed divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900">
|
||||
|
||||
Reference in New Issue
Block a user