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:
J606y
2026-07-20 21:58:57 +08:00
co-authored by Claude Fable 5
parent 9e072cefec
commit 0df490961b
6 changed files with 182 additions and 8 deletions
@@ -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">