feat(ui): add status avatar interactions
This commit is contained in:
parent
359caca672
commit
4a54d6fbb7
@ -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 {
|
||||
// ---- 眼睛 morph(idle 竖线 ↔ 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);
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user