feat(frontend): enrich interface micro-interactions

Add responsive hover reactions across dashboard, account, usage, settings, login, and navigation surfaces. Keep brand motion distinct, pair enter and leave states, and respect reduced-motion preferences.

Refine sidebar transitions, dashboard heartbeat, trend feedback, and 15-minute metric buckets without animating page content.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-16 01:07:33 +08:00
co-authored by Codex
parent e4999e463c
commit 54772dda22
14 changed files with 615 additions and 51 deletions
+2 -2
View File
@@ -70,11 +70,11 @@ const loadingIconSize: Record<ButtonSize, number> = {
}
const classes = computed(() => [
'inline-flex items-center justify-center border-0 font-[720] leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none',
'inline-flex items-center justify-center border-0 font-[720] leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
props.iconOnly && (props.variant === 'default' || props.variant === 'primary')
? iconOnlyDefaultClasses
: variantClasses[props.variant],
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
import { onBeforeUnmount, useTemplateRef, watch } from 'vue'
type MotionIconVariant = 'random' | 'brand'
type MotionPhase = 'enter' | 'leave'
type MotionTimeline = ReturnType<typeof gsap.timeline>
type ReactionBuilder = (root: HTMLElement, glyph: HTMLElement) => MotionTimeline
interface MotionReaction {
enter: ReactionBuilder
leave: ReactionBuilder
}
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
variant?: MotionIconVariant
}>(),
{
as: 'span',
variant: 'random',
},
)
const preferredMotion = usePreferredReducedMotion()
const icon = useTemplateRef<HTMLElement>('icon')
const glyph = useTemplateRef<HTMLElement>('glyph')
const randomReactions: MotionReaction[] = [
{ enter: liftAndBloomEnter, leave: liftAndBloomLeave },
{ enter: lookAroundEnter, leave: lookAroundLeave },
{ enter: doubleTapEnter, leave: doubleTapLeave },
{ enter: counterSwingEnter, leave: counterSwingLeave },
{ enter: shortOrbitEnter, leave: shortOrbitLeave },
]
const brandReaction: MotionReaction = {
enter: brandGreetingEnter,
leave: brandGreetingLeave,
}
let previousReactionIndex = -1
let activeReaction: MotionReaction | undefined
let activeTimeline: MotionTimeline | undefined
let isPointerInside = false
function motionTargets() {
return [icon.value, glyph.value].filter((target): target is HTMLElement => Boolean(target))
}
function clearMotionStyles(element: HTMLElement) {
gsap.set(element, { clearProps: 'transform,transformOrigin,willChange' })
}
function stopActiveTimeline(targets: HTMLElement[]) {
activeTimeline?.kill()
activeTimeline = undefined
gsap.killTweensOf(targets)
}
function resetMotion() {
const targets = motionTargets()
stopActiveTimeline(targets)
activeReaction = undefined
for (const target of targets) clearMotionStyles(target)
}
function createMotionTimeline(root: HTMLElement, glyphElement: HTMLElement, phase: MotionPhase) {
const targets = [root, glyphElement]
stopActiveTimeline(targets)
gsap.set(targets, { willChange: 'transform' })
let timeline: MotionTimeline
timeline = gsap.timeline({
defaults: { ease: 'power2.out' },
onComplete: () => {
if (activeTimeline !== timeline) return
activeTimeline = undefined
if (phase === 'leave' && !isPointerInside) {
activeReaction = undefined
for (const target of targets) clearMotionStyles(target)
return
}
gsap.set(targets, { clearProps: 'willChange' })
},
})
activeTimeline = timeline
return timeline
}
function nextRandomReaction() {
const index =
previousReactionIndex < 0
? Math.floor(Math.random() * randomReactions.length)
: (previousReactionIndex + 1 + Math.floor(Math.random() * (randomReactions.length - 1))) %
randomReactions.length
previousReactionIndex = index
return randomReactions[index]!
}
function liftAndBloomEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { y: -3, scale: 1.055, duration: 0.22 }, 0)
.to(
glyphElement,
{ y: -0.5, rotation: -4, scale: 1.13, duration: 0.26, ease: 'back.out(2.1)' },
0.02,
)
return timeline
}
function liftAndBloomLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(glyphElement, { y: 0, rotation: 0, scale: 1, duration: 0.24, ease: 'back.out(1.6)' }, 0)
.to(root, { y: 0, scale: 1, duration: 0.22, ease: 'power3.out' }, 0.02)
return timeline
}
function lookAroundEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { y: -1, scale: 1.025, duration: 0.2 }, 0)
.to(glyphElement, { x: -2.5, rotation: -7, duration: 0.13 }, 0)
.to(glyphElement, { x: 2.5, rotation: 7, duration: 0.17, ease: 'power2.inOut' }, 0.13)
.to(
glyphElement,
{ x: 1, rotation: 3, scale: 1.06, duration: 0.16, ease: 'back.out(1.7)' },
0.3,
)
return timeline
}
function lookAroundLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(glyphElement, { x: 0, rotation: 0, scale: 1, duration: 0.24, ease: 'back.out(1.7)' }, 0)
.to(root, { y: 0, scale: 1, duration: 0.22, ease: 'power3.out' }, 0.02)
return timeline
}
function doubleTapEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { y: 1, scale: 0.965, duration: 0.09, ease: 'power2.in' }, 0)
.to(glyphElement, { y: 1.5, scale: 0.92, duration: 0.09, ease: 'power2.in' }, 0)
.to(root, { y: -3, scale: 1.055, duration: 0.17, ease: 'back.out(2.2)' }, 0.09)
.to(glyphElement, { y: -2, scale: 1.12, duration: 0.19, ease: 'back.out(2.4)' }, 0.09)
return timeline
}
function doubleTapLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(root, { y: 0.5, scale: 0.985, duration: 0.1, ease: 'power2.in' }, 0)
.to(glyphElement, { y: 0.75, scale: 0.97, duration: 0.1, ease: 'power2.in' }, 0)
.to(root, { y: 0, scale: 1, duration: 0.17, ease: 'back.out(1.8)' }, 0.1)
.to(glyphElement, { y: 0, scale: 1, duration: 0.18, ease: 'back.out(1.9)' }, 0.1)
return timeline
}
function counterSwingEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { x: -1.5, y: -1.5, rotation: -4, scale: 1.035, duration: 0.14 }, 0)
.to(glyphElement, { x: 2, rotation: 8, duration: 0.14 }, 0)
.to(root, { x: 1, y: -1, rotation: 2, duration: 0.15, ease: 'power2.inOut' }, 0.14)
.to(glyphElement, { x: -1, rotation: -5, duration: 0.15, ease: 'power2.inOut' }, 0.14)
return timeline
}
function counterSwingLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(root, { x: 0, y: 0, rotation: 0, scale: 1, duration: 0.23, ease: 'back.out(1.6)' }, 0)
.to(glyphElement, { x: 0, rotation: 0, duration: 0.21, ease: 'back.out(1.8)' }, 0.02)
return timeline
}
function shortOrbitEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { y: -1.5, scale: 1.025, duration: 0.18 }, 0)
.to(glyphElement, { x: 2.5, y: -2, rotation: 5, duration: 0.13 }, 0)
.to(glyphElement, { x: -2, y: -0.5, rotation: -5, duration: 0.15 }, 0.13)
.to(
glyphElement,
{ x: 0.75, y: -1, rotation: 2, scale: 1.08, duration: 0.18, ease: 'back.out(1.8)' },
0.28,
)
return timeline
}
function shortOrbitLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(glyphElement, {
x: 0,
y: 0,
rotation: 0,
scale: 1,
duration: 0.24,
ease: 'back.out(1.7)',
})
.to(root, { y: 0, scale: 1, duration: 0.22, ease: 'power3.out' }, 0.02)
return timeline
}
function brandGreetingEnter(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'enter')
timeline
.to(root, { scale: 0.965, duration: 0.09, ease: 'power2.in' }, 0)
.to(glyphElement, { y: 1, scale: 0.92, duration: 0.09, ease: 'power2.in' }, 0)
.to(root, { scale: 1, duration: 0.18, ease: 'back.out(2.4)' }, 0.09)
.to(
glyphElement,
{ y: -1.5, rotation: -5, scale: 1.28, duration: 0.22, ease: 'back.out(3)' },
0.08,
)
.to(glyphElement, { x: 1, rotation: 4, scale: 1.2, duration: 0.13, ease: 'power1.inOut' }, 0.3)
.to(
glyphElement,
{ x: -0.5, rotation: -2, scale: 1.24, duration: 0.11, ease: 'power1.inOut' },
0.43,
)
return timeline
}
function brandGreetingLeave(root: HTMLElement, glyphElement: HTMLElement) {
const timeline = createMotionTimeline(root, glyphElement, 'leave')
timeline
.to(glyphElement, {
x: 0.35,
y: -0.5,
rotation: 1,
scale: 1.08,
duration: 0.14,
ease: 'power2.in',
})
.to(root, { scale: 0.985, duration: 0.14, ease: 'power2.in' }, 0)
.to(
glyphElement,
{ x: 0, y: 0, rotation: 0, scale: 1, duration: 0.2, ease: 'back.out(1.8)' },
0.12,
)
.to(root, { scale: 1, duration: 0.2, ease: 'back.out(1.7)' }, 0.12)
return timeline
}
function playEnterReaction() {
isPointerInside = true
if (preferredMotion.value === 'reduce') {
resetMotion()
return
}
const root = icon.value
const glyphElement = glyph.value
if (!root || !glyphElement) return
activeReaction = props.variant === 'brand' ? brandReaction : nextRandomReaction()
activeReaction.enter(root, glyphElement)
}
function playLeaveReaction() {
isPointerInside = false
if (preferredMotion.value === 'reduce') {
resetMotion()
return
}
const root = icon.value
const glyphElement = glyph.value
if (!root || !glyphElement || !activeReaction) {
resetMotion()
return
}
activeReaction.leave(root, glyphElement)
}
watch(preferredMotion, (motion) => {
if (motion === 'reduce') resetMotion()
})
onBeforeUnmount(resetMotion)
</script>
<template>
<component
:is="props.as"
ref="icon"
@pointerenter="playEnterReaction"
@pointerleave="playLeaveReaction"
>
<span ref="glyph" class="inline-flex items-center justify-center">
<slot />
</span>
</component>
</template>
+118 -8
View File
@@ -1,7 +1,16 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
import { usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
useTemplateRef,
watch,
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import {
@@ -21,6 +30,7 @@ import {
} from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import { useSystemUpdate } from '@/composables/useSystemUpdate'
import { useAuthStore } from '@/stores/modules/auth'
import { useUiStore } from '@/stores/modules/ui'
@@ -67,7 +77,30 @@ const isActive = (path: string) => {
return route.path.startsWith(path)
}
const activeNavIndex = computed(() => {
const index = navItems.findIndex((item) => isActive(item.path))
return Math.max(0, index)
})
const activeNavIndicatorStyle = computed(() => ({
transform: `translate3d(0, ${activeNavIndex.value * 58}px, 0)`,
}))
const navFeedbackMuted = shallowRef(false)
const { start: restoreNavFeedback, stop: stopNavFeedbackRestore } = useTimeoutFn(
() => {
navFeedbackMuted.value = false
},
300,
{ immediate: false },
)
function muteNavFeedbackDuringMove() {
navFeedbackMuted.value = true
stopNavFeedbackRestore()
restoreNavFeedback()
}
function navigate(path: string) {
muteNavFeedbackDuringMove()
void router.push(path)
emit('navigate')
}
@@ -98,6 +131,7 @@ async function handleLogout() {
const sidebarEl = ref<HTMLElement | null>(null)
const brandLabelEl = ref<HTMLElement | null>(null)
const navSignalEl = useTemplateRef<HTMLElement>('navSignal')
const systemUpdateOpen = shallowRef(false)
const aboutOpen = shallowRef(false)
const isCollapsed = computed(() => !props.mobile && Boolean(props.collapsed))
@@ -217,6 +251,29 @@ function animateSidebarWidth(collapsed: boolean) {
})
}
function animateNavSignal() {
const signal = navSignalEl.value
if (!signal) return
gsap.killTweensOf(signal)
if (prefersReducedMotion()) {
gsap.set(signal, { opacity: 0, xPercent: -70 })
return
}
gsap.fromTo(
signal,
{ opacity: 0.48, xPercent: -70 },
{
opacity: 0,
xPercent: 120,
duration: 0.52,
ease: 'power2.out',
overwrite: true,
},
)
}
onMounted(() => {
gsap.set(sidebarEl.value, {
width: sidebarWidth.value,
@@ -231,6 +288,7 @@ onMounted(() => {
})
}
animateSidebarLabels(isCollapsed.value)
gsap.set(navSignalEl.value, { opacity: 0, xPercent: -70 })
void loadVersion().catch(() => undefined)
})
@@ -251,6 +309,30 @@ watch(
animateSidebarLabels(Boolean(collapsed))
},
)
watch(
() => route.path,
async (path, previousPath) => {
if (path === previousPath) return
muteNavFeedbackDuringMove()
await nextTick()
animateNavSignal()
},
{ flush: 'post' },
)
onBeforeUnmount(() => {
stopNavFeedbackRestore()
const targets = [sidebarEl.value, brandLabelEl.value, navSignalEl.value].filter(
(target): target is HTMLElement => Boolean(target),
)
gsap.killTweensOf(targets)
const labels = sidebarEl.value?.querySelectorAll<HTMLElement>('.sidebar-label')
if (labels?.length) {
gsap.killTweensOf(labels)
}
})
</script>
<template>
@@ -266,11 +348,13 @@ watch(
class="mt-6 grid h-12 grid-cols-[44px_minmax(0,1fr)] items-center"
:class="isCollapsed ? 'w-11 justify-start' : 'w-full gap-3'"
>
<span
<BaseMotionIcon
aria-hidden="true"
variant="brand"
class="inline-flex size-11 items-center justify-center relative -top-0.5 rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-primary)"
>
<Cat :size="27" stroke-width="2" />
</span>
</BaseMotionIcon>
<span
v-show="brandLabelVisible"
ref="brandLabelEl"
@@ -304,17 +388,32 @@ watch(
</span>
</div>
<nav class="mt-7 grid gap-3" :class="isCollapsed ? '' : 'w-full'" aria-label="主导航">
<nav
class="relative mt-7 grid gap-3"
:class="isCollapsed ? 'w-11.5' : 'w-full'"
aria-label="主导航"
>
<span
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 h-11.5 overflow-hidden rounded-(--cp-icon-button-radius) bg-(--cp-bg-nav-active) transition-transform duration-260 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:style="activeNavIndicatorStyle"
>
<span ref="navSignal" class="sidebar-active-signal absolute inset-y-0 left-0 w-2/3" />
</span>
<button
v-for="item in navItems"
:key="item.label"
type="button"
class="inline-flex h-11.5 cursor-pointer items-center rounded-(--cp-icon-button-radius) border-0 text-sm leading-[1.15] transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
class="relative z-10 inline-flex h-11.5 cursor-pointer items-center rounded-(--cp-icon-button-radius) border-0 text-sm leading-[1.15] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
:class="[
isCollapsed ? 'w-11.5 justify-center' : 'w-full gap-3 px-4',
isActive(item.path)
? 'bg-(--cp-bg-nav-active) font-bold text-(--cp-text-primary)'
: 'bg-transparent font-semibold text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) hover:text-(--cp-text-primary)',
? navFeedbackMuted
? 'bg-transparent font-bold text-(--cp-text-primary) transition-none'
: 'bg-transparent font-bold text-(--cp-text-primary) transition-colors duration-200'
: navFeedbackMuted
? 'bg-transparent font-semibold text-(--cp-text-secondary) transition-none'
: 'bg-transparent font-semibold text-(--cp-text-secondary) transition-colors duration-200 hover:bg-(--cp-bg-subtle) hover:text-(--cp-text-primary)',
]"
@click="navigate(item.path)"
>
@@ -420,3 +519,14 @@ watch(
<AppAboutModal v-model="aboutOpen" />
<SystemUpdateModal v-model="systemUpdateOpen" />
</template>
<style scoped>
.sidebar-active-signal {
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--cp-info) 9%, transparent),
transparent
);
}
</style>
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import { AlertTriangle, Gauge, ShieldCheck, Users } from '@lucide/vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
const props = defineProps<{
summary: any
@@ -80,12 +81,13 @@ function overviewIconClass(tone: string) {
{{ item.caption }}
</p>
</div>
<span
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-9 shrink-0 items-center justify-center self-start rounded-lg"
:class="overviewIconClass(item.tone)"
>
<component :is="item.icon" class="size-4.5" />
</span>
</BaseMotionIcon>
</div>
</BaseCard>
</div>
@@ -3,9 +3,9 @@ import { clamp } from 'es-toolkit'
import { computed } from 'vue'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import AccountIdentityCell from '../../accounts/components/AccountIdentityCell.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import AccountIdentityCell from '@/views/accounts/components/AccountIdentityCell.vue'
const props = defineProps<{
accounts: any[]
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue'
const props = withDefaults(
defineProps<{
updatedAt?: string
}>(),
{
updatedAt: '',
},
)
const preferredMotion = usePreferredReducedMotion()
const heartbeatDot = useTemplateRef<HTMLElement>('heartbeatDot')
const heartbeatRing = useTemplateRef<HTMLElement>('heartbeatRing')
const statusLabel = computed(() =>
props.updatedAt ? `自动刷新 30s,最近刷新 ${props.updatedAt}` : '自动刷新 30s,等待首次刷新',
)
function pulse() {
const dot = heartbeatDot.value
const ring = heartbeatRing.value
if (!dot || !ring || preferredMotion.value === 'reduce') return
gsap.killTweensOf([dot, ring])
gsap.fromTo(
dot,
{ scale: 0.72, opacity: 0.55 },
{
scale: 1,
opacity: 1,
duration: 0.42,
ease: 'back.out(2.2)',
clearProps: 'transform,opacity',
},
)
gsap.fromTo(
ring,
{ scale: 0.55, opacity: 0.34 },
{
scale: 2.65,
opacity: 0,
duration: 0.65,
ease: 'power2.out',
clearProps: 'transform,opacity',
},
)
}
watch(
() => props.updatedAt,
async (updatedAt, previousUpdatedAt) => {
if (!updatedAt || updatedAt === previousUpdatedAt) return
await nextTick()
pulse()
},
)
onBeforeUnmount(() => {
gsap.killTweensOf([heartbeatDot.value, heartbeatRing.value])
})
</script>
<template>
<span class="inline-flex items-center gap-1.75" :aria-label="statusLabel" :title="statusLabel">
<span
aria-hidden="true"
class="relative inline-flex size-2 shrink-0 items-center justify-center"
>
<span ref="heartbeatRing" class="absolute size-2 rounded-full bg-(--cp-success) opacity-0" />
<span ref="heartbeatDot" class="relative size-1.5 rounded-full bg-(--cp-success)" />
</span>
<span aria-hidden="true">自动刷新 30s</span>
</span>
</template>
@@ -3,8 +3,9 @@ import type { EChartsOption } from 'echarts'
import type { Component } from 'vue'
import { computed } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseChart from '../../../components/charts/BaseChart.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import AnimatedMetricValue from './AnimatedMetricValue.vue'
@@ -110,12 +111,13 @@ const sparklineOption = computed<EChartsOption | null>(() => {
<template>
<BaseCard as="article" :padded="false" class="relative h-38.5 w-full px-6 pt-5">
<div class="flex items-start gap-3">
<span
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-8.5 shrink-0 items-center justify-center rounded-[10px]"
:class="iconToneClasses[metric.tone]"
>
<component :is="metric.icon" :size="18" />
</span>
</BaseMotionIcon>
<span class="mt-1 text-[13px] leading-[1.15] font-[650] text-(--cp-text-secondary)">{{
metric.title
}}</span>
@@ -3,8 +3,8 @@ import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BasePopover from '../../../components/base/BasePopover.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BasePopover from '@/components/base/BasePopover.vue'
import HealthTimelinePointPopover from './HealthTimelinePointPopover.vue'
import {
formatHealthCount,
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePreferredReducedMotion } from '@vueuse/core'
import type { EChartsOption } from 'echarts'
import { storeToRefs } from 'pinia'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseChart from '../../../components/charts/BaseChart.vue'
import BaseEmpty from '../../../components/base/BaseEmpty.vue'
import BaseSegmented from '../../../components/base/BaseSegmented.vue'
import { useUiStore } from '../../../stores/modules/ui'
import { computed, shallowRef, watch } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useUiStore } from '@/stores/modules/ui'
type TrendKind = 'usage' | 'latency' | 'errors'
@@ -27,6 +28,10 @@ const tabs = [
]
const activeKind = defineModel<TrendKind>('kind', { required: true })
const { themeRevision } = storeToRefs(useUiStore())
const preferredMotion = usePreferredReducedMotion()
const hoveredSummaryLabel = shallowRef<string>()
const pinnedSummaryLabel = shallowRef<string>()
const activeSummaryLabel = computed(() => hoveredSummaryLabel.value ?? pinnedSummaryLabel.value)
const hasSamples = computed(() =>
props.points.some(
@@ -54,8 +59,8 @@ const chartOption = computed<EChartsOption>(() => {
return {
...coordinate,
series,
animationDuration: 420,
animationDurationUpdate: 220,
animationDuration: preferredMotion.value === 'reduce' ? 0 : 420,
animationDurationUpdate: preferredMotion.value === 'reduce' ? 0 : 220,
animationEasing: 'cubicOut',
animationEasingUpdate: 'cubicOut',
tooltip: {
@@ -363,6 +368,7 @@ function latencyRangeValues() {
function lineSeries(name: string, data: (number | null)[], color: string, options: any = {}) {
const area = options.area ?? false
const muted = isSeriesMuted(name)
return {
name,
@@ -381,10 +387,18 @@ function lineSeries(name: string, data: (number | null)[], color: string, option
color,
type: options.lineType ?? 'solid',
width: options.width ?? 2.2,
opacity: muted ? 0.18 : 1,
},
itemStyle: { color, opacity: muted ? 0.18 : 1 },
emphasis: { focus: 'series' as const },
blur: {
lineStyle: { opacity: 0.2 },
itemStyle: { opacity: 0.2 },
areaStyle: { opacity: 0.05 },
},
itemStyle: { color },
areaStyle: area
? {
opacity: muted ? 0.12 : 1,
color: {
type: 'linear' as const,
x: 0,
@@ -402,6 +416,8 @@ function lineSeries(name: string, data: (number | null)[], color: string, option
}
function barSeries(name: string, data: (number | null)[], color: string, options: any = {}) {
const muted = isSeriesMuted(name)
return {
name,
type: 'bar' as const,
@@ -413,9 +429,11 @@ function barSeries(name: string, data: (number | null)[], color: string, options
z: options.z ?? 2,
itemStyle: {
color,
opacity: options.opacity ?? 0.72,
opacity: muted ? 0.14 : (options.opacity ?? 0.72),
borderRadius: options.borderRadius ?? [3, 3, 0, 0],
},
emphasis: { focus: 'series' as const },
blur: { itemStyle: { opacity: 0.18 } },
}
}
@@ -431,6 +449,31 @@ function trendColor(label: string, fallbackVar: string, fallback: string) {
function handleTrendChange(value: string) {
emit('trendChange', value as TrendKind)
}
function focusSummarySeries(label: string) {
hoveredSummaryLabel.value = label
}
function blurSummarySeries() {
hoveredSummaryLabel.value = undefined
}
function toggleSummarySeries(label: string) {
pinnedSummaryLabel.value = pinnedSummaryLabel.value === label ? undefined : label
}
function isSeriesMuted(name: string) {
return Boolean(activeSummaryLabel.value && activeSummaryLabel.value !== name)
}
function isSummarySeriesActive(label: string) {
return activeSummaryLabel.value === label
}
watch(activeKind, () => {
hoveredSummaryLabel.value = undefined
pinnedSummaryLabel.value = undefined
})
</script>
<template>
@@ -449,21 +492,33 @@ function handleTrendChange(value: string) {
<div
class="grid h-14.25 min-w-0 grid-cols-3 gap-1.5 rounded-xl bg-(--cp-bg-subtle)/45 p-1.5"
>
<div
<button
v-for="item in props.summary"
:key="item.label"
class="grid min-w-0 grid-cols-[8px_minmax(0,1fr)] items-center gap-x-2 rounded-lg px-2.5 py-2"
type="button"
:aria-label="`突出显示${item.label}曲线`"
:aria-pressed="pinnedSummaryLabel === item.label"
class="group grid min-w-0 grid-cols-[8px_minmax(0,1fr)] items-center gap-x-2 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
@mouseenter="focusSummarySeries(item.label)"
@mouseleave="blurSummarySeries"
@focus="focusSummarySeries(item.label)"
@blur="blurSummarySeries"
@click="toggleSummarySeries(item.label)"
>
<i
class="size-2 rounded-full"
aria-hidden="true"
class="size-2 rounded-full transition-transform duration-200 ease-out group-hover:scale-125 motion-reduce:transition-none"
:style="summaryMarkerStyle(item)"
:class="{
'bg-(--cp-info)': item.tone === 'info',
'bg-(--cp-success)': item.tone === 'success',
'bg-(--cp-warning)': item.tone === 'warning',
'bg-(--cp-danger)': item.tone === 'danger',
'bg-(--cp-normal)': item.tone === 'normal',
}"
:class="[
isSummarySeriesActive(item.label) ? 'scale-125' : undefined,
{
'bg-(--cp-info)': item.tone === 'info',
'bg-(--cp-success)': item.tone === 'success',
'bg-(--cp-warning)': item.tone === 'warning',
'bg-(--cp-danger)': item.tone === 'danger',
'bg-(--cp-normal)': item.tone === 'normal',
},
]"
/>
<span class="grid min-w-0 gap-1">
<span class="truncate text-[10px] leading-none font-[680] text-(--cp-text-secondary)">
@@ -476,7 +531,7 @@ function handleTrendChange(value: string) {
{{ item.value }}
</strong>
</span>
</div>
</button>
</div>
<div class="relative h-55 w-full overflow-hidden">
@@ -10,6 +10,8 @@ import { formatDateTime } from '@/utils/date'
type DashboardTrendKind = 'usage' | 'latency' | 'errors'
const METRIC_SPARKLINE_BUCKETS = 12
export function useDashboard(): any {
const activeTrendKind = ref<DashboardTrendKind>('usage')
const metrics = ref<any[]>(metricCards(emptyDashboardSummary()))
@@ -284,7 +286,10 @@ export function useDashboard(): any {
}
if (lastActiveIndex < 0) return []
return points.slice(Math.max(0, lastActiveIndex - 8), lastActiveIndex + 1)
return points.slice(
Math.max(0, lastActiveIndex - (METRIC_SPARKLINE_BUCKETS - 1)),
lastActiveIndex + 1,
)
}
function formatDashboardCompactNumber(value: number) {
+7 -2
View File
@@ -4,6 +4,7 @@ import { RefreshCw } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AccountOverviewCard from './components/AccountOverviewCard.vue'
import DashboardHeartbeat from './components/DashboardHeartbeat.vue'
import UsageRecordCard from './components/UsageRecordCard.vue'
import MetricCard from './components/MetricCard.vue'
import RequestHealthTimelineCard from './components/RequestHealthTimelineCard.vue'
@@ -26,6 +27,7 @@ const {
poolSummary,
capacityInfo,
rotationStrategy,
lastRefreshedAt,
refresh,
loadTrend,
} = useDashboard()
@@ -38,8 +40,11 @@ const {
<h1 class="mt-0 text-[34px] leading-[1.15] font-extrabold mb-0 text-(--cp-text-primary)">
系统概览
</h1>
<p class="mt-2.5 text-[15px] leading-[1.15] font-semibold mb-0 text-(--cp-text-secondary)">
当日统计 · 自动刷新 30s
<p
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
>
<span>当日统计</span>
<DashboardHeartbeat :updated-at="lastRefreshedAt" />
</p>
</div>
@@ -5,6 +5,7 @@ import { computed, shallowRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
type ThemeName = 'light' | 'dark'
type PasswordInputType = 'password' | 'text'
@@ -56,9 +57,9 @@ function togglePasswordVisible(): void {
<header class="flex min-w-0 items-center justify-between gap-4.5 max-[560px]:gap-3.5">
<div class="flex min-w-0 items-center gap-3">
<span class="login-logo">
<BaseMotionIcon aria-hidden="true" variant="brand" class="login-logo">
<Cat :size="20" :stroke-width="2.1" />
</span>
</BaseMotionIcon>
<span class="grid min-w-0 gap-1">
<strong
class="text-[17px] leading-[1.12] font-semibold text-(--login-brand-title) max-[560px]:text-[15px]"
@@ -3,6 +3,7 @@ import { Copy, KeyRound, Trash2 } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
interface AdminApiKeyStatus {
exists: boolean
@@ -62,11 +63,12 @@ const emit = defineEmits<{
class="flex min-h-16 items-center justify-between gap-4 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3"
>
<div class="flex min-w-0 items-center gap-3">
<span
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) text-(--cp-normal) shadow-(--cp-shadow-control)"
>
<KeyRound class="size-4" />
</span>
</BaseMotionIcon>
<div class="min-w-0">
<p class="m-0 text-[13px] leading-[1.15] font-[720] text-(--cp-text-primary)">
{{ status.exists ? '已启用' : '未生成' }}
@@ -2,6 +2,8 @@
import { Activity, Database, FileText, Timer } from '@lucide/vue'
import { computed } from 'vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
const props = defineProps<{
summary: any
}>()
@@ -52,12 +54,13 @@ const items = computed(() => [
:key="item.key"
class="grid min-h-23 grid-cols-[36px_minmax(0,1fr)] items-stretch gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-surface) px-4 py-3 shadow-(--cp-shadow-card)"
>
<span
<BaseMotionIcon
aria-hidden="true"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-(--cp-input-radius-base)"
:class="item.tone"
>
<component :is="item.icon" class="size-4.5" />
</span>
</BaseMotionIcon>
<div class="flex min-w-0 flex-col justify-between py-0.5">
<span class="block text-[12px] leading-none font-bold text-(--cp-text-muted)">
{{ item.label }}