mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
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:
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user