mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 05:21:20 +00:00
Merge pull request #4661 from J606y/fix/mobile-adaptation
fix(mobile): 修复 iOS 页面自动缩放并完善运维监控及全站移动端适配
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user