feat(ui): add status avatar interactions

This commit is contained in:
JOJO 2026-07-05 00:57:06 +08:00
parent 359caca672
commit 4a54d6fbb7
3 changed files with 221 additions and 30 deletions

View File

@ -6,6 +6,7 @@
:style="{ width: size + 'px', height: size + 'px' }"
viewBox="0 0 200 200"
aria-hidden="true"
@click="handleAvatarClick"
>
<!-- flat-top 圆角正六边形外框 -->
<path
@ -82,6 +83,7 @@ const props = withDefaults(
);
const emit = defineEmits<{
(event: 'active-index', index: number): void;
(event: 'poke'): void;
}>();
// clipPath id
@ -139,15 +141,23 @@ function buildFaceInner(tool: ToolDef): string {
// ---- morphidle 线 work demo ----
const EYE_SHAPES: Record<string, string> = {
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<SVGPathElement, number>();
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<string | null>(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(() => {
<style scoped lang="scss">
.status-avatar {
display: block;
cursor: pointer;
overflow: visible;
color: currentColor;
flex-shrink: 0;
@ -401,6 +522,11 @@ onBeforeUnmount(() => {
.sa-shared.hidden {
opacity: 0;
}
.status-avatar--work .sa-shared {
animation: sa-bob 2.4s ease-in-out infinite;
}
.sa-icon {
opacity: 0;
transform: scale(0.92);

View File

@ -632,24 +632,28 @@
:tracking="avatarStatusSafe.tracking"
:size="34"
@active-index="handleStatusAvatarActiveIndex"
@poke="handleStatusAvatarPoke"
/>
<span
v-if="statusAvatarText"
class="conversation-status-avatar__text"
:class="{ 'conversation-status-avatar__text--animated': statusAvatarTextAnimated }"
>
<template v-if="statusAvatarTextAnimated">
<span
v-for="(letter, letterIndex) in statusAvatarLetters"
:key="`${letter}-${letterIndex}`"
class="conversation-status-avatar__letter"
:style="{ animationDelay: `${letterIndex * 0.08}s` }"
>
{{ letter }}
</span>
</template>
<template v-else>{{ statusAvatarText }}</template>
</span>
<Transition name="conversation-status-avatar-text-fade" mode="out-in">
<span
v-if="statusAvatarText"
:key="statusAvatarText"
class="conversation-status-avatar__text"
:class="{ 'conversation-status-avatar__text--animated': statusAvatarTextAnimated }"
>
<template v-if="statusAvatarTextAnimated">
<span
v-for="(letter, letterIndex) in statusAvatarLetters"
:key="`${letter}-${letterIndex}`"
class="conversation-status-avatar__letter"
:style="{ animationDelay: `${letterIndex * 0.08}s` }"
>
{{ letter }}
</span>
</template>
<template v-else>{{ statusAvatarText }}</template>
</span>
</Transition>
</div>
</div>
<div class="messages-bottom-spacer" aria-hidden="true"></div>
@ -1032,6 +1036,30 @@ const getFilteredMessagesSafe = () =>
const latestMessageIndex = computed(() => getFilteredMessagesSafe().length - 1);
const statusAvatarActiveIndex = ref(0);
const statusAvatarRef = ref<HTMLElement | null>(null);
const statusAvatarPokeText = ref('');
let statusAvatarPokeTimer: number | null = null;
const STATUS_AVATAR_POKE_TEXTS = [
'不要!',
'停下来!',
'别戳啦!',
'哎呀!',
'你戳到我了!',
'轻一点嘛!',
'我不是按钮!',
'不许乱点!',
'呜……别戳我了……',
'六边形也是会痛的!',
'再戳我要变形了!',
'我会紧张的……',
'正在假装镇定……',
'Token 掉了一地!',
'上下文被你戳乱了!',
'防戳模式启动失败',
'系统提示AI 被戳了一下',
'警告:检测到手欠行为',
'我记住你了!',
'戳回去!'
];
const avatarStatusSafe = computed(() => ({
mode: props.avatarStatus?.mode || 'idle',
toolKeys: Array.isArray(props.avatarStatus?.toolKeys) ? props.avatarStatus!.toolKeys! : [],
@ -1040,6 +1068,9 @@ const avatarStatusSafe = computed(() => ({
tracking: props.avatarStatus?.tracking !== false
}));
const statusAvatarText = computed(() => {
if (statusAvatarPokeText.value && avatarStatusSafe.value.mode === 'idle') {
return statusAvatarPokeText.value;
}
if (avatarStatusSafe.value.mode === 'tool' && avatarStatusSafe.value.toolTexts.length > 1) {
const idx = statusAvatarActiveIndex.value % avatarStatusSafe.value.toolTexts.length;
return avatarStatusSafe.value.toolTexts[idx] || avatarStatusSafe.value.text;
@ -1050,13 +1081,31 @@ const statusAvatarTextAnimated = computed(() => {
return avatarStatusSafe.value.mode === 'work' && !!statusAvatarText.value;
});
const statusAvatarLetters = computed(() => Array.from(statusAvatarText.value || ''));
const clearStatusAvatarPokeText = () => {
if (statusAvatarPokeTimer !== null) {
window.clearTimeout(statusAvatarPokeTimer);
statusAvatarPokeTimer = null;
}
statusAvatarPokeText.value = '';
};
const handleStatusAvatarActiveIndex = (index: number) => {
statusAvatarActiveIndex.value = Number.isFinite(index) ? Math.max(0, index) : 0;
};
const handleStatusAvatarPoke = () => {
if (avatarStatusSafe.value.mode !== 'idle') return;
const texts = STATUS_AVATAR_POKE_TEXTS;
statusAvatarPokeText.value = texts[Math.floor(Math.random() * texts.length)] || '不要!';
if (statusAvatarPokeTimer !== null) window.clearTimeout(statusAvatarPokeTimer);
statusAvatarPokeTimer = window.setTimeout(() => {
statusAvatarPokeText.value = '';
statusAvatarPokeTimer = null;
}, 1800);
};
watch(
() => [props.avatarStatus?.mode, props.avatarStatus?.toolKeys?.join('|')],
() => {
statusAvatarActiveIndex.value = 0;
if (avatarStatusSafe.value.mode !== 'idle') clearStatusAvatarPokeText();
}
);
const nowMs = ref(Date.now());
@ -1959,6 +2008,7 @@ onBeforeUnmount(() => {
clearInterval(timerHandle);
timerHandle = null;
}
clearStatusAvatarPokeText();
});
const isStackable = (action: any) =>

View File

@ -151,6 +151,21 @@
letter-spacing: 0.01em;
}
.conversation-status-avatar-text-fade-enter-active,
.conversation-status-avatar-text-fade-leave-active {
transition: opacity 0.18s ease;
}
.conversation-status-avatar-text-fade-enter-from,
.conversation-status-avatar-text-fade-leave-to {
opacity: 0;
}
.conversation-status-avatar-text-fade-enter-to,
.conversation-status-avatar-text-fade-leave-from {
opacity: 1;
}
.conversation-status-avatar__text--animated {
display: inline-flex;
align-items: center;