refactor(web): share dashboard tone maps and retire dead surfaces

Hoist the tone-to-class maps duplicated between MetricCard and AccountOverviewCard into dashboard constants, move the generic ECharts tooltip parameter helpers to components/charts/tooltip.ts shared by the dashboard trend chart and usage charts, derive the health popover's segment and metric lists from one outcome metadata source, and rebuild useOpsErrorsTable on the shared usePagedQuery composable (same stale-response guard and empty-page clamp, first-paint loading preserved). Remove the dead trend-loading prop chain, the unused showAvatar prop, and two unused exports. Built CSS unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zyycn
2026-07-26 21:57:25 +08:00
co-authored by Claude
parent 81e51db0b6
commit 245f87ef35
15 changed files with 132 additions and 179 deletions
+14
View File
@@ -0,0 +1,14 @@
// ECharts tooltip 回调参数的通用安全取值:参数形状不受控(数组/对象混合),
// 统一在此收敛类型判断。
export function tooltipRows(params: unknown): Record<string, unknown>[] {
const values = Array.isArray(params) ? params : [params]
return values.filter(
(value): value is Record<string, unknown> => typeof value === 'object' && value !== null,
)
}
export function tooltipIndex(source: unknown) {
if (typeof source !== 'object' || source === null || !('dataIndex' in source))
return -1
return typeof source.dataIndex === 'number' ? source.dataIndex : -1
}
@@ -14,14 +14,12 @@ const props = withDefaults(
account: AccountIdentity
size?: 'md' | 'lg'
showPlan?: boolean
showAvatar?: boolean
titleMode?: 'local-part' | 'email'
metaPosition?: 'title' | 'secondary'
}>(),
{
size: 'md',
showPlan: false,
showAvatar: true,
titleMode: 'local-part',
metaPosition: 'title',
},
@@ -72,7 +70,6 @@ const avatarClass = computed(() => {
<template>
<div class="flex min-w-0 items-center gap-3">
<span
v-if="showAvatar"
class="inline-flex shrink-0 items-center justify-center rounded-lg font-[820]"
:class="[avatarSizeClass, avatarClass]"
>
@@ -39,6 +39,28 @@ interface AccountConnectionTestOptions {
onAccountStatus: (accountId: string, status: string) => void
}
// 后端把"选账号阶段失败"和"上游真的挂了"拆成了不同 providerErrorCode,
// 前者是可自愈的等待,不该让人以为上游不可用。
const CONNECTION_TEST_ERROR_HINTS: Record<string, string> = {
account_cooling_down: '账号冷却中',
account_capacity_busy: '账号并发已占满或请求间隔未到,请稍后重试',
no_eligible_account: '该账号没有可用于所选模型的凭据',
account_selector_unavailable: '账号调度状态暂时不可读,请稍后重试',
}
function connectionTestErrorText(event: any) {
const fallback = event.error || '测试连接失败'
const hint = CONNECTION_TEST_ERROR_HINTS[event.providerErrorCode]
if (!hint)
return fallback
if (event.providerErrorCode !== 'account_cooling_down')
return hint
const seconds = /retry in (\d+)s/.exec(fallback)?.[1]
return seconds
? `${hint},约 ${seconds} 秒后自动恢复(上游流被中断后触发)`
: `${hint}(上游流被中断后触发)`
}
export function useAccountConnectionTest(options: AccountConnectionTestOptions) {
const showConnectionTestModal = shallowRef(false)
const testingAccount = shallowRef<any>(null)
@@ -262,7 +284,7 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions)
finishConnectionTest('success')
}
else {
connectionTestError.value = event.error || '测试连接失败'
connectionTestError.value = connectionTestErrorText(event)
appendConnectionTestLog(connectionTestError.value, 'danger')
finishConnectionTest('error')
}
@@ -271,9 +293,9 @@ export function useAccountConnectionTest(options: AccountConnectionTestOptions)
}
if (event.type === 'error') {
applyAccountStatus(event.accountStatus)
connectionTestError.value = event.error || '测试连接失败'
connectionTestError.value = connectionTestErrorText(event)
const detail = event.providerErrorCode || event.providerErrorType
? { code: event.providerErrorCode, type: event.providerErrorType }
? { code: event.providerErrorCode, type: event.providerErrorType, message: event.error }
: undefined
appendConnectionTestLog(connectionTestError.value, 'danger', detail)
finishConnectionTest('error')
+1 -1
View File
@@ -162,7 +162,7 @@ export function orderedPanelQuotaWindows(windows: AccountQuotaWindow[]) {
)
}
export function quotaWindowPercent(window: AccountQuotaWindow) {
function quotaWindowPercent(window: AccountQuotaWindow) {
return clamp(window.usedPercent ?? 0, 0, 100)
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { dashboardSnapshotView } from '../composables/presenter'
import type { dashboardSnapshotView, MetricTone } from '../composables/presenter'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { clamp } from 'es-toolkit'
@@ -9,6 +9,7 @@ import BaseEmpty from '@/components/base/BaseEmpty.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountIdentityCell from '@/views/accounts/components/AccountIdentityCell.vue'
import AccountUsageWindow from '@/views/accounts/components/AccountUsageWindow.vue'
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
type DashboardSnapshot = ReturnType<typeof dashboardSnapshotView>
@@ -69,6 +70,14 @@ const strategyLabel = computed(() => {
return map[s] || s
})
function toneIconClass(tone: string) {
return metricToneIconClasses[tone as MetricTone]
}
function toneValueClass(tone: string) {
return metricToneValueClasses[tone as MetricTone]
}
const statusRows = computed(() => {
const p = props.pool
if (!p) {
@@ -141,22 +150,6 @@ const statusBars = computed(() => {
{ pct: unavailable, cls: 'bg-(--cp-danger)' },
].filter(b => b.pct > 0)
})
const rowToneClasses: Record<string, string> = {
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
info: 'bg-(--cp-info-bg) text-(--cp-info)',
success: 'bg-(--cp-success-bg) text-(--cp-success)',
warning: 'bg-(--cp-warning-bg) text-(--cp-warning)',
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
}
const valueToneClasses: Record<string, string> = {
normal: 'text-(--cp-normal-text)',
info: 'text-(--cp-info-text)',
success: 'text-(--cp-success-text)',
warning: 'text-(--cp-warning-text)',
danger: 'text-(--cp-danger-text)',
}
</script>
<template>
@@ -330,7 +323,7 @@ const valueToneClasses: Record<string, string> = {
>
<span
class="inline-flex size-7 items-center justify-center rounded-[9px]"
:class="rowToneClasses[row.tone]"
:class="toneIconClass(row.tone)"
>
<component :is="row.icon" :size="16" />
</span>
@@ -344,7 +337,7 @@ const valueToneClasses: Record<string, string> = {
</span>
<strong
class="col-start-4 text-right font-mono text-[17px] leading-[1.15] font-[760] tabular-nums"
:class="valueToneClasses[row.tone]"
:class="toneValueClass(row.tone)"
>{{ row.value }}</strong>
</div>
</div>
@@ -18,40 +18,8 @@ const eligibleRequests = computed(() => props.point.successRequests + props.poin
const observedRequests = computed(
() => eligibleRequests.value + props.point.cancelledRequests + props.point.callerErrorRequests,
)
const outcomeSegments = computed(() => {
const total = observedRequests.value
if (total === 0)
return []
return [
{
label: '成功',
value: props.point.successRequests,
className: 'bg-(--cp-success)',
},
{
label: '服务失败',
value: props.point.failedRequests,
className: 'bg-(--cp-danger)',
},
{
label: '客户端取消',
value: props.point.cancelledRequests,
className: 'bg-(--cp-normal)',
},
{
label: '调用方错误',
value: props.point.callerErrorRequests,
className: 'bg-(--cp-info)',
},
]
.filter(item => item.value > 0)
.map(item => ({
...item,
percentage: (item.value / total) * 100,
}))
})
const metricItems = computed(() => [
// 四类结果的标签/取值/配色单一来源;分段条与明细行都由此派生。
const outcomeMeta = computed(() => [
{
label: '成功',
value: props.point.successRequests,
@@ -77,6 +45,20 @@ const metricItems = computed(() => [
valueClass: 'text-(--cp-info-text)',
},
])
const outcomeSegments = computed(() => {
const total = observedRequests.value
if (total === 0)
return []
return outcomeMeta.value
.map(item => ({ label: item.label, value: item.value, className: item.dotClass }))
.filter(item => item.value > 0)
.map(item => ({
...item,
percentage: (item.value / total) * 100,
}))
})
const metricItems = outcomeMeta
</script>
<template>
@@ -7,28 +7,13 @@ import BaseCard from '@/components/base/BaseCard.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
import AnimatedMetricValue from './AnimatedMetricValue.vue'
const props = defineProps<{
metric: MetricCardView
}>()
const iconToneClasses: Record<MetricTone, string> = {
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
info: 'bg-(--cp-info-bg) text-(--cp-info)',
success: 'bg-(--cp-success-bg) text-(--cp-success)',
warning: 'bg-(--cp-warning-bg) text-(--cp-warning)',
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
}
const detailToneClasses: Record<MetricTone, string> = {
normal: 'text-(--cp-normal-text)',
info: 'text-(--cp-info-text)',
success: 'text-(--cp-success-text)',
warning: 'text-(--cp-warning-text)',
danger: 'text-(--cp-danger-text)',
}
const trendToneClasses: Record<MetricTone, string> = {
normal: 'bg-(--cp-normal-text)',
info: 'bg-(--cp-info-text)',
@@ -89,7 +74,7 @@ const sparklineOption = computed<EChartsOption | null>(() => {
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-[10px]"
:class="iconToneClasses[metric.tone]"
:class="metricToneIconClasses[metric.tone]"
>
<component :is="metric.icon" :size="18" />
</BaseMotionIcon>
@@ -135,7 +120,7 @@ const sparklineOption = computed<EChartsOption | null>(() => {
}}</span>
<b
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
:class="metric.details[0]?.tone ? detailToneClasses[metric.details[0].tone] : undefined"
:class="metric.details[0]?.tone ? metricToneValueClasses[metric.details[0].tone] : undefined"
>
{{ metric.details[0]?.value }}
</b>
@@ -148,7 +133,7 @@ const sparklineOption = computed<EChartsOption | null>(() => {
}}</span>
<b
class="min-w-0 truncate font-mono text-xs leading-none font-bold tabular-nums"
:class="metric.details[1]?.tone ? detailToneClasses[metric.details[1].tone] : undefined"
:class="metric.details[1]?.tone ? metricToneValueClasses[metric.details[1].tone] : undefined"
>
{{ metric.details[1]?.value }}
</b>
@@ -14,7 +14,6 @@ type TrendView = ReturnType<typeof dashboardTrendView>
const props = defineProps<{
points: TrendView['points']
summary: TrendView['summary']
loading?: boolean
}>()
const emit = defineEmits<{
@@ -231,7 +231,7 @@ function recentTrendWindow(points: DashboardTrendPoint[]) {
)
}
export function formatDashboardCompactNumber(value: number) {
function formatDashboardCompactNumber(value: number) {
const normalized = Math.max(0, Math.round(value))
if (normalized < 1_000)
return new Intl.NumberFormat('zh-CN').format(normalized)
@@ -17,7 +17,6 @@ export function useDashboard() {
const trend = shallowRef(dashboardTrendView(null))
const loading = shallowRef(false)
const refreshing = shallowRef(false)
const trendLoading = shallowRef(false)
const lastRefreshedAt = shallowRef('')
let trendRequestId = 0
@@ -75,7 +74,6 @@ export function useDashboard() {
activeTrendKind.value = trendKind
const requestId = ++trendRequestId
try {
trendLoading.value = true
const result = await getDashboardTrend({ kind: trendKind })
if (isCurrentTrendRequest(requestId, trendKind))
trend.value = dashboardTrendView(result)
@@ -83,26 +81,16 @@ export function useDashboard() {
catch {
// 趋势请求失败时保留当前趋势,下一次刷新继续尝试。
}
finally {
if (isCurrentTrendRequest(requestId, trendKind))
trendLoading.value = false
}
}
async function loadDashboardSnapshot() {
const trendKind = activeTrendKind.value
const requestId = ++trendRequestId
try {
const summary = await getDashboardSummary({ kind: trendKind })
snapshot.value = dashboardSnapshotView(summary)
lastRefreshedAt.value = formatDateTime()
if (isCurrentTrendRequest(requestId, trendKind)) {
trend.value = dashboardTrendView(summary.trend)
}
}
finally {
if (isCurrentTrendRequest(requestId, trendKind))
trendLoading.value = false
const summary = await getDashboardSummary({ kind: trendKind })
snapshot.value = dashboardSnapshotView(summary)
lastRefreshedAt.value = formatDateTime()
if (isCurrentTrendRequest(requestId, trendKind)) {
trend.value = dashboardTrendView(summary.trend)
}
}
@@ -121,7 +109,6 @@ export function useDashboard() {
return {
loading,
refreshing,
trendLoading,
activeTrendKind,
lastRefreshedAt,
metrics,
@@ -4,6 +4,7 @@ import type { dashboardTrendView, normalizeDashboardTrendKind } from './presente
import { usePreferredReducedMotion } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { tooltipIndex } from '@/components/charts/tooltip'
import { useThemeColor } from '@/composables/useThemeColor'
type TrendKind = ReturnType<typeof normalizeDashboardTrendKind>
@@ -514,13 +515,6 @@ function tooltipValue(source: unknown, key: string) {
return typeof value === 'number' || typeof value === 'string' ? String(value) : ''
}
function tooltipIndex(source: unknown) {
if (typeof source !== 'object' || source === null || !('dataIndex' in source))
return -1
const value = (source as Record<string, unknown>).dataIndex
return typeof value === 'number' ? value : -1
}
function trendPointProperty(point: TrendPoint | undefined, key: string) {
if (!point || !(key in point))
return undefined
+19
View File
@@ -1,3 +1,22 @@
import type { MetricTone } from './composables/presenter'
// tone 到 Tailwind 类的唯一映射;仪表盘各卡片共用。
export const metricToneIconClasses: Record<MetricTone, string> = {
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
info: 'bg-(--cp-info-bg) text-(--cp-info)',
success: 'bg-(--cp-success-bg) text-(--cp-success)',
warning: 'bg-(--cp-warning-bg) text-(--cp-warning)',
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
}
export const metricToneValueClasses: Record<MetricTone, string> = {
normal: 'text-(--cp-normal-text)',
info: 'text-(--cp-info-text)',
success: 'text-(--cp-success-text)',
warning: 'text-(--cp-warning-text)',
danger: 'text-(--cp-danger-text)',
}
export type HealthStatus
= 'future' | 'no_data' | 'unavailable' | 'unstable' | 'low_sample' | 'stable'
-2
View File
@@ -16,7 +16,6 @@ import { useDashboard } from './composables/useDashboard'
const {
loading,
refreshing,
trendLoading,
activeTrendKind,
metrics,
trendPoints,
@@ -69,7 +68,6 @@ const {
v-model:kind="activeTrendKind"
:points="trendPoints"
:summary="trendSummary"
:loading="trendLoading"
@trend-change="loadTrend"
/>
<WireProfileCard :profiles="wireProfiles" />
@@ -1,83 +1,57 @@
import type { Ref } from 'vue'
import type { UsageTimeRangeParams } from './useUsageTimeRange'
import { watchDebounced } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getOpsErrors } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage, withMinimumDuration } from '@/utils/async'
export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>) {
const loading = shallowRef(true)
const refreshing = shallowRef(false)
const records = shallowRef<Awaited<ReturnType<typeof getOpsErrors>>['items']>([])
const page = shallowRef(1)
const pageSize = shallowRef(10)
const total = shallowRef(0)
const searchQuery = shallowRef('')
const failureClass = shallowRef('')
const route = shallowRef('')
let loadRequestId = 0
const query = usePagedQuery({
initialPageSize: 10,
load: ({ page, pageSize }) => getOpsErrors({
page,
pageSize,
search: searchQuery.value.trim() || undefined,
failureClass: failureClass.value.trim() || undefined,
route: route.value.trim() || undefined,
...timeRangeParams.value,
}),
onError: error => toast.error(errorMessage(error, '加载错误明细失败')),
})
// 首屏进入即处于加载态;首次 execute 完成后由 usePagedQuery 维护。
query.loading.value = true
const pagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
total: total.value,
page: query.page.value,
pageSize: query.pageSize.value,
total: query.total.value,
}))
async function load() {
const requestId = ++loadRequestId
try {
loading.value = true
const result = await getOpsErrors({
page: page.value,
pageSize: pageSize.value,
search: searchQuery.value.trim() || undefined,
failureClass: failureClass.value.trim() || undefined,
route: route.value.trim() || undefined,
...timeRangeParams.value,
})
if (requestId !== loadRequestId)
return
records.value = result.items
pageSize.value = result.page.pageSize
total.value = result.page.total
page.value = result.page.page
if (records.value.length === 0 && total.value > 0 && page.value > 1) {
page.value = clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY)
await load()
}
}
catch (error: unknown) {
if (requestId !== loadRequestId)
return
toast.error(errorMessage(error, '加载错误明细失败'))
}
finally {
if (requestId === loadRequestId)
loading.value = false
}
}
function handlePageChange(nextPage: number) {
page.value = nextPage
void load()
query.page.value = nextPage
void query.execute()
}
function handlePageSizeChange(nextPageSize: number) {
pageSize.value = nextPageSize
page.value = 1
void load()
query.pageSize.value = nextPageSize
query.page.value = 1
void query.execute()
}
async function refresh() {
if (refreshing.value || loading.value)
if (refreshing.value || query.loading.value)
return
refreshing.value = true
try {
await withMinimumDuration(load)
await withMinimumDuration(() => query.execute())
}
finally {
refreshing.value = false
@@ -87,23 +61,23 @@ export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangePa
watchDebounced(
[searchQuery, failureClass, route],
() => {
page.value = 1
void load()
query.page.value = 1
void query.execute()
},
{ debounce: 250 },
)
watch(timeRangeParams, () => {
page.value = 1
void load()
query.page.value = 1
void query.execute()
})
onMounted(load)
onMounted(() => void query.execute())
return {
loading,
loading: query.loading,
refreshing,
records,
records: query.items,
searchQuery,
failureClass,
route,
+1 -12
View File
@@ -85,15 +85,4 @@ export function usageLegend(theme: UsageChartPalette, data: string[]) {
}
}
export function tooltipRows(params: unknown): Record<string, unknown>[] {
const values = Array.isArray(params) ? params : [params]
return values.filter(
(value): value is Record<string, unknown> => typeof value === 'object' && value !== null,
)
}
export function tooltipIndex(source: unknown) {
if (typeof source !== 'object' || source === null || !('dataIndex' in source))
return -1
return typeof source.dataIndex === 'number' ? source.dataIndex : -1
}
export { tooltipIndex, tooltipRows } from '@/components/charts/tooltip'