From 4a54d6fbb7f2d3192082d1895ffabebefb77ba2a Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 5 Jul 2026 00:57:06 +0800 Subject: [PATCH] feat(ui): add status avatar interactions --- static/src/components/avatar/StatusAvatar.vue | 152 ++++++++++++++++-- static/src/components/chat/ChatArea.vue | 84 ++++++++-- .../styles/components/chat/_chat-area.scss | 15 ++ 3 files changed, 221 insertions(+), 30 deletions(-) diff --git a/static/src/components/avatar/StatusAvatar.vue b/static/src/components/avatar/StatusAvatar.vue index 27bea0b..817c426 100644 --- a/static/src/components/avatar/StatusAvatar.vue +++ b/static/src/components/avatar/StatusAvatar.vue @@ -6,6 +6,7 @@ :style="{ width: size + 'px', height: size + 'px' }" viewBox="0 0 200 200" aria-hidden="true" + @click="handleAvatarClick" > (); // 每个实例独立的 clipPath id,避免多实例冲突 @@ -139,15 +141,23 @@ function buildFaceInner(tool: ToolDef): string { // ---- 眼睛 morph(idle 竖线 ↔ work 提示符),照搬 demo ---- const EYE_SHAPES: Record = { idle: 'M 0,-11 Q 0,-5.5 0,0 Q 0,5.5 0,11 Q 0,5.5 0,0 Q 0,-5.5 0,-11', + blink: 'M 0,0 Q 0,0 0,0 Q 0,0 0,0 Q 0,0 0,0 Q 0,0 0,0', + nervousLeft: 'M -10,-12 Q -2,-6 6,0 Q -2,6 -10,12 Q -2,6 6,0 Q -2,-6 -10,-12', + nervousRight: 'M 10,-12 Q 2,-6 -6,0 Q 2,6 10,12 Q 2,6 -6,0 Q 2,-6 10,-12', work: 'M 0,0 Q 8.5,0 17,0 Q 25.5,0 34,0 Q 25.5,0 17,0 Q 8.5,0 0,0' }; const EYE_LEFT_ORIGIN = { x: 84, y: 96 }; const EYE_RIGHT_ORIGIN = { x: 116, y: 96 }; +const NERVOUS_LEFT_ORIGIN = { x: 82, y: 96 }; +const NERVOUS_RIGHT_ORIGIN = { x: 118, y: 96 }; const WORK_ORIGIN = { x: 117, y: 100 }; const WORK_ROTATION_LEFT = 150; const WORK_ROTATION_RIGHT = 210; +const DEFAULT_EYE_TRANSITION = 'transform 0.55s cubic-bezier(0.22, 1, 0.36, 1)'; +const NERVOUS_EYE_TRANSITION = 'transform 0.22s cubic-bezier(0.65, 0, 0.35, 1)'; -let currentEyeShape = 'idle'; +let currentLeftEyeShape = 'idle'; +let currentRightEyeShape = 'idle'; function cubicBezier(p1x: number, p1y: number, p2x: number, p2y: number) { const cx = 3 * p1x; @@ -170,6 +180,7 @@ function cubicBezier(p1x: number, p1y: number, p2x: number, p2y: number) { }; } const easeOut = cubicBezier(0.22, 1, 0.36, 1); +const easeInOut = cubicBezier(0.65, 0, 0.35, 1); function parsePathNumbers(d: string): number[] { return (d.match(/[-\d.]+/g) || []).map(Number); @@ -179,7 +190,13 @@ function buildPath(n: number[]): string { } const morphRaf = new Map(); -function morphShape(eye: SVGPathElement | null, fromShape: string, toShape: string, duration = 550) { +function morphShape( + eye: SVGPathElement | null, + fromShape: string, + toShape: string, + duration = 550, + easing = easeOut +) { if (!eye) return; eye.setAttribute('d', EYE_SHAPES[fromShape]); const fromNums = parsePathNumbers(EYE_SHAPES[fromShape]); @@ -189,7 +206,7 @@ function morphShape(eye: SVGPathElement | null, fromShape: string, toShape: stri if (prev) cancelAnimationFrame(prev); const step = (now: number) => { const progress = Math.min((now - startTime) / duration, 1); - const eased = easeOut(progress); + const eased = easing(progress); const cur = fromNums.map((v, i) => v + (toNums[i] - v) * eased); eye.setAttribute('d', buildPath(cur)); if (progress < 1) { @@ -202,16 +219,34 @@ function morphShape(eye: SVGPathElement | null, fromShape: string, toShape: stri } function applyWorkTransform() { - if (eyeLeftRef.value) + if (eyeLeftRef.value) { + eyeLeftRef.value.style.transition = DEFAULT_EYE_TRANSITION; eyeLeftRef.value.style.transform = `translate(${WORK_ORIGIN.x}px, ${WORK_ORIGIN.y}px) rotate(${WORK_ROTATION_LEFT}deg)`; - if (eyeRightRef.value) + } + if (eyeRightRef.value) { + eyeRightRef.value.style.transition = DEFAULT_EYE_TRANSITION; eyeRightRef.value.style.transform = `translate(${WORK_ORIGIN.x}px, ${WORK_ORIGIN.y}px) rotate(${WORK_ROTATION_RIGHT}deg)`; + } } function applyIdleTransform() { - if (eyeLeftRef.value) + if (eyeLeftRef.value) { + eyeLeftRef.value.style.transition = DEFAULT_EYE_TRANSITION; eyeLeftRef.value.style.transform = `translate(${EYE_LEFT_ORIGIN.x}px, ${EYE_LEFT_ORIGIN.y}px)`; - if (eyeRightRef.value) + } + if (eyeRightRef.value) { + eyeRightRef.value.style.transition = DEFAULT_EYE_TRANSITION; eyeRightRef.value.style.transform = `translate(${EYE_RIGHT_ORIGIN.x}px, ${EYE_RIGHT_ORIGIN.y}px)`; + } +} +function applyNervousTransform() { + if (eyeLeftRef.value) { + eyeLeftRef.value.style.transition = NERVOUS_EYE_TRANSITION; + eyeLeftRef.value.style.transform = `translate(${NERVOUS_LEFT_ORIGIN.x}px, ${NERVOUS_LEFT_ORIGIN.y}px)`; + } + if (eyeRightRef.value) { + eyeRightRef.value.style.transition = NERVOUS_EYE_TRANSITION; + eyeRightRef.value.style.transform = `translate(${NERVOUS_RIGHT_ORIGIN.x}px, ${NERVOUS_RIGHT_ORIGIN.y}px)`; + } } // ---- 眼睛鼠标追踪 ---- @@ -231,7 +266,8 @@ function stopTracking() { if (faceRef.value) faceRef.value.style.transform = 'translate(0px, 0px)'; } function trackEyes() { - if (!(props.tracking && props.mode === 'idle') || !svgRef.value) { + const mode = internalMode.value || props.mode; + if (!(props.tracking && (mode === 'idle' || mode === 'nervous')) || !svgRef.value) { stopTracking(); return; } @@ -254,7 +290,11 @@ function trackEyes() { // ---- 并行工具轮播 ---- let carouselTimer: number | null = null; let faceSwitchTimer: number | null = null; +let blinkTimer: number | null = null; +let nervousTimer: number | null = null; +let isBlinking = false; let carouselIdx = 0; +const internalMode = ref(null); function stopCarousel() { if (carouselTimer) clearInterval(carouselTimer); carouselTimer = null; @@ -263,6 +303,73 @@ function stopFaceSwitchTimer() { if (faceSwitchTimer) clearTimeout(faceSwitchTimer); faceSwitchTimer = null; } +function stopAutoBlink() { + if (blinkTimer) clearTimeout(blinkTimer); + blinkTimer = null; +} +function stopNervousTimer() { + if (nervousTimer) clearTimeout(nervousTimer); + nervousTimer = null; +} +function wait(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} +function scheduleAutoBlink() { + stopAutoBlink(); + if (props.mode !== 'idle' || internalMode.value) return; + const delay = 2800 + Math.random() * 3600; + blinkTimer = window.setTimeout(async () => { + await blinkEyes(); + scheduleAutoBlink(); + }, delay); +} +async function blinkEyes() { + if (props.mode !== 'idle' || internalMode.value || isBlinking) return; + isBlinking = true; + const blinkCount = Math.random() < 0.5 ? 1 : 2; + for (let i = 0; i < blinkCount; i++) { + morphShape(eyeLeftRef.value, 'idle', 'blink', 150, easeInOut); + morphShape(eyeRightRef.value, 'idle', 'blink', 150, easeInOut); + await wait(120); + if (props.mode !== 'idle' || internalMode.value) { + isBlinking = false; + return; + } + await wait(45); + morphShape(eyeLeftRef.value, 'blink', 'idle', 210, easeInOut); + morphShape(eyeRightRef.value, 'blink', 'idle', 210, easeInOut); + await wait(i === blinkCount - 1 ? 200 : 250); + } + isBlinking = false; +} +function triggerNervous() { + if (props.mode !== 'idle') return; + internalMode.value = 'nervous'; + stopAutoBlink(); + stopNervousTimer(); + stopTracking(); + stopCarousel(); + stopFaceSwitchTimer(); + activeFaceKey.value = null; + sharedVisible.value = true; + applyNervousTransform(); + morphShape(eyeLeftRef.value, currentLeftEyeShape, 'nervousLeft', 220, easeInOut); + morphShape(eyeRightRef.value, currentRightEyeShape, 'nervousRight', 220, easeInOut); + currentLeftEyeShape = 'nervousLeft'; + currentRightEyeShape = 'nervousRight'; + if (props.tracking) trackEyes(); + nervousTimer = window.setTimeout(() => { + if (internalMode.value === 'nervous') { + internalMode.value = null; + applyState(); + } + }, 500); +} +function handleAvatarClick() { + if (props.mode !== 'idle') return; + emit('poke'); + triggerNervous(); +} function setIconFace(key: string, animated = true) { const fromShared = sharedVisible.value && !activeFaceKey.value; sharedVisible.value = false; @@ -306,7 +413,12 @@ function startCarousel(keys: string[]) { // ---- 状态应用 ---- function applyState() { - const mode = props.mode; + if (props.mode !== 'idle' && internalMode.value) { + internalMode.value = null; + stopNervousTimer(); + } + const mode = internalMode.value || props.mode; + if (mode !== 'idle') stopAutoBlink(); if (mode === 'tool') { stopTracking(); const keys = (props.toolKeys || []).filter(Boolean); @@ -320,17 +432,21 @@ function applyState() { setIconFace('think'); return; } + if (mode === 'nervous') { + return; + } // idle / work:显示 shared 眼睛并 morph const target = mode === 'work' ? 'work' : 'idle'; const fromIcon = !!activeFaceKey.value; activeFaceKey.value = null; const revealShared = () => { sharedVisible.value = true; - morphShape(eyeLeftRef.value, currentEyeShape, target); - morphShape(eyeRightRef.value, currentEyeShape, target); + morphShape(eyeLeftRef.value, currentLeftEyeShape, target); + morphShape(eyeRightRef.value, currentRightEyeShape, target); if (target === 'work') applyWorkTransform(); else applyIdleTransform(); - currentEyeShape = target; + currentLeftEyeShape = target; + currentRightEyeShape = target; }; if (fromIcon) { faceSwitchTimer = window.setTimeout(() => { @@ -343,12 +459,14 @@ function applyState() { if (target === 'idle' && props.tracking) { stopTracking(); trackEyes(); + scheduleAutoBlink(); } else { stopTracking(); + stopAutoBlink(); } } -watch(() => [props.mode, props.toolKeys, props.tracking], () => applyState(), { deep: true }); +watch(() => [props.mode, props.toolKeys, props.tracking, internalMode.value], () => applyState(), { deep: true }); onMounted(() => { applyIdleTransform(); @@ -360,6 +478,8 @@ onBeforeUnmount(() => { stopTracking(); stopCarousel(); stopFaceSwitchTimer(); + stopAutoBlink(); + stopNervousTimer(); morphRaf.forEach((id) => cancelAnimationFrame(id)); morphRaf.clear(); }); @@ -368,6 +488,7 @@ onBeforeUnmount(() => {