Merge pull request #4661 from J606y/fix/mobile-adaptation

fix(mobile): 修复 iOS 页面自动缩放并完善运维监控及全站移动端适配
This commit is contained in:
Wesley Liddick
2026-07-21 14:43:30 +08:00
committed by GitHub
20 changed files with 298 additions and 46 deletions
@@ -102,8 +102,8 @@
<div v-else>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
<div class="max-h-[420px] overflow-y-auto">
<table class="w-full text-sm">
<div class="max-h-[420px] overflow-auto">
<table class="w-full min-w-max text-sm">
<thead class="sticky top-0 z-[1]">
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-700">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userEmail') }}</th>
@@ -126,8 +126,8 @@
<div v-else>
<!-- 表格 -->
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
<div class="max-h-[420px] overflow-y-auto">
<table class="w-full text-sm">
<div class="max-h-[420px] overflow-auto">
<table class="w-full min-w-max text-sm">
<thead class="sticky top-0 z-[1]">
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-700">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userEmail') }}</th>
@@ -71,11 +71,11 @@
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="displayEndpointStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<div v-else-if="displayEndpointStats.length > 0 && chartData" class="flex flex-col items-center gap-4 sm:flex-row sm:gap-6">
<div class="h-48 w-48 shrink-0">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<div class="max-h-48 w-full min-w-0 flex-1 overflow-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
@@ -33,11 +33,11 @@
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="displayGroupStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<div v-else-if="displayGroupStats.length > 0 && chartData" class="flex flex-col items-center gap-4 sm:flex-row sm:gap-6">
<div class="h-48 w-48 shrink-0">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<div class="max-h-48 w-full min-w-0 flex-1 overflow-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
@@ -101,12 +101,12 @@
</div>
<div
v-else-if="activeView === 'model_distribution' && displayModelStats.length > 0 && chartData"
class="flex items-center gap-6"
class="flex flex-col items-center gap-4 sm:flex-row sm:gap-6"
>
<div class="h-48 w-48">
<div class="h-48 w-48 shrink-0">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<div class="max-h-48 w-full min-w-0 flex-1 overflow-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
@@ -182,11 +182,11 @@
>
{{ t('admin.dashboard.failedToLoad') }}
</div>
<div v-else-if="rankingDisplayItems.length > 0 && rankingChartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<div v-else-if="rankingDisplayItems.length > 0 && rankingChartData" class="flex flex-col items-center gap-4 sm:flex-row sm:gap-6">
<div class="h-48 w-48 shrink-0">
<Doughnut :data="rankingChartData" :options="rankingDoughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<div class="max-h-48 w-full min-w-0 flex-1 overflow-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
@@ -27,12 +27,12 @@
<LoadingSpinner size="md" />
</div>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.modelDistribution') }}</h3>
<div class="flex items-center gap-6">
<div class="h-48 w-48">
<div class="flex flex-col items-center gap-4 sm:flex-row sm:gap-6">
<div class="h-48 w-48 shrink-0">
<Doughnut v-if="modelData" :data="modelData" :options="doughnutOptions" />
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<div class="max-h-48 w-full min-w-0 flex-1 overflow-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
+16
View File
@@ -5,8 +5,23 @@ import router from './router'
import i18n, { initI18n } from './i18n'
import { useAppStore } from '@/stores/app'
import { updateFavicon } from '@/utils/branding'
import { isIOSDevice } from '@/utils/device'
import './style.css'
function initIOSViewportZoomFix() {
// iOS Safari 在输入框字号小于 16px 时聚焦会自动放大页面,且失焦后不会恢复。
// 限制 maximum-scale 可阻止该行为;iOS 10+ 用户仍可双指手动缩放,不影响可访问性。
// 仅在 iOS 设备上注入,避免影响 Android Chrome 的手动缩放能力。
if (!isIOSDevice()) return
const viewport = document.querySelector('meta[name="viewport"]')
if (!viewport) return
const content = viewport.getAttribute('content') || ''
if (/maximum-scale/i.test(content)) return
viewport.setAttribute('content', `${content}, maximum-scale=1.0`)
}
function initThemeClass() {
const savedTheme = localStorage.getItem('theme')
const shouldUseDark =
@@ -18,6 +33,7 @@ function initThemeClass() {
async function bootstrap() {
// Apply theme class globally before app mount to keep all routes consistent.
initThemeClass()
initIOSViewportZoomFix()
const app = createApp(App)
const pinia = createPinia()
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { detectMobileDevice } from '../device'
import { detectIOSDevice, detectMobileDevice } from '../device'
describe('detectMobileDevice', () => {
it('prefers userAgentData.mobile when available', () => {
@@ -53,3 +53,43 @@ describe('detectMobileDevice', () => {
})).toBe(false)
})
})
describe('detectIOSDevice', () => {
it('recognizes iPhone from the UA token', () => {
expect(detectIOSDevice({
navigator: {
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1',
maxTouchPoints: 5,
},
})).toBe(true)
})
it('recognizes iPadOS desktop mode via touch capability', () => {
expect(detectIOSDevice({
navigator: {
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15',
platform: 'MacIntel',
maxTouchPoints: 5,
},
})).toBe(true)
})
it('keeps Android devices as non-iOS', () => {
expect(detectIOSDevice({
navigator: {
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/136.0 Mobile Safari/537.36',
maxTouchPoints: 5,
},
})).toBe(false)
})
it('keeps desktop macOS without touch as non-iOS', () => {
expect(detectIOSDevice({
navigator: {
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/136.0 Safari/537.36',
platform: 'MacIntel',
maxTouchPoints: 0,
},
})).toBe(false)
})
})
+19
View File
@@ -20,6 +20,7 @@ interface DeviceDetectionEnvironment {
const MOBILE_UA_RE = /\b(Mobi|Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|IEMobile)\b/i
const TABLET_UA_RE = /\b(iPad|Tablet)\b/i
const IOS_UA_RE = /\b(iPhone|iPad|iPod)\b/i
function matchesQuery(
matchMedia: DeviceDetectionEnvironment['matchMedia'],
@@ -60,3 +61,21 @@ export function isMobileDevice(): boolean {
matchMedia: typeof window !== 'undefined' ? window.matchMedia.bind(window) : undefined,
})
}
export function detectIOSDevice(env: DeviceDetectionEnvironment = {}): boolean {
const nav = env.navigator
if (!nav) return false
const userAgent = nav.userAgent || ''
const maxTouchPoints = nav.maxTouchPoints ?? 0
// iPadOS 13+ 桌面模式下 UA 伪装成 macOS,需要靠触控点数识别。
const isIPadOSDesktopMode = nav.platform === 'MacIntel' && maxTouchPoints > 1
return IOS_UA_RE.test(userAgent) || isIPadOSDesktopMode
}
export function isIOSDevice(): boolean {
if (typeof navigator === 'undefined') return false
return detectIOSDevice({ navigator })
}
+2 -2
View File
@@ -1101,7 +1101,7 @@
</div>
<div
v-if="createForm.peak_rate_enabled"
class="mb-4 grid grid-cols-3 gap-3"
class="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3"
>
<div>
<label class="input-label">{{ t("admin.groups.peakRate.peakStart") }}</label>
@@ -2615,7 +2615,7 @@
</div>
<div
v-if="editForm.peak_rate_enabled"
class="mb-4 grid grid-cols-3 gap-3"
class="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3"
>
<div>
<label class="input-label">{{ t("admin.groups.peakRate.peakStart") }}</label>
+4 -4
View File
@@ -834,7 +834,7 @@
</span>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Action -->
<div>
<label
@@ -6344,7 +6344,7 @@
</button>
</div>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-700">
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-dark-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-800">
<tr>
@@ -6664,7 +6664,7 @@
</div>
<template v-if="form.payment_enabled">
<!-- Row 1: Product name -->
<div class="grid grid-cols-3 gap-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label class="input-label">{{
t("admin.settings.payment.productNamePrefix")
@@ -7083,7 +7083,7 @@
</p>
</div>
<!-- Row 5: Help image + text -->
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="input-label">{{
t("admin.settings.payment.helpImage")
@@ -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">
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<Props>(), {
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
<div class="space-y-2">
<div class="h-6 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-3 w-80 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
<div class="h-3 w-80 max-w-full animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div v-if="!props.fullscreen" class="flex flex-wrap items-center gap-3">
<div class="h-9 w-[140px] animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
@@ -92,7 +92,7 @@ const props = withDefaults(defineProps<Props>(), {
<div v-for="i in 6" :key="i" class="flex items-center justify-between gap-4 rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
<div class="flex-1 space-y-2">
<div class="h-3 w-56 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="h-3 w-80 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
<div class="h-3 w-80 max-w-full animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div class="h-7 w-20 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
</div>
@@ -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">
+7 -7
View File
@@ -38,7 +38,7 @@
<span v-else>{{ index + 1 }}</span>
</div>
<span
class="ml-2 text-sm font-medium"
class="ml-2 hidden text-sm font-medium sm:inline"
:class="
currentStep >= index
? 'text-gray-900 dark:text-white'
@@ -50,7 +50,7 @@
</div>
<div
v-if="index < steps.length - 1"
class="mx-3 h-0.5 w-12"
class="mx-2 h-0.5 w-6 sm:mx-3 sm:w-12"
:class="currentStep > index ? 'bg-primary-500' : 'bg-gray-200 dark:bg-dark-700'"
></div>
</template>
@@ -70,7 +70,7 @@
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('setup.database.host') }}</label>
<input
@@ -103,7 +103,7 @@
<Toggle v-model="formData.redis.enable_tls" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('setup.database.username') }}</label>
<input
@@ -124,7 +124,7 @@
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('setup.database.databaseName') }}</label>
<input
@@ -195,7 +195,7 @@
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('setup.redis.host') }}</label>
<input
@@ -216,7 +216,7 @@
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('setup.redis.username') }}</label>
<input
+6 -3
View File
@@ -1050,7 +1050,7 @@
<div
v-if="groupSelectorKeyId !== null && dropdownPosition"
ref="dropdownRef"
class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-max min-w-[380px] overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-max max-w-[calc(100vw-16px)] overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 sm:min-w-[380px] dark:bg-dark-800 dark:ring-white/10"
style="pointer-events: auto !important;"
:style="{
top: dropdownPosition.top !== undefined ? dropdownPosition.top + 'px' : undefined,
@@ -1605,20 +1605,23 @@ const openGroupSelector = (key: ApiKey) => {
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect()
const dropdownEstHeight = 400 // estimated max dropdown height
const dropdownEstWidth = Math.min(380, window.innerWidth - 16)
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
// 夹取 left,避免窄屏下浮层超出视口右缘
const left = Math.max(8, Math.min(rect.left, window.innerWidth - dropdownEstWidth - 8))
if (spaceBelow < dropdownEstHeight && spaceAbove > spaceBelow) {
// Not enough space below, pop upward
dropdownPosition.value = {
bottom: window.innerHeight - rect.top + 4,
left: rect.left
left
}
} else {
// Default: pop downward
dropdownPosition.value = {
top: rect.bottom + 4,
left: rect.left
left
}
}
}