master
qinzhenpen 2 days ago
commit c3b1e99267

@ -0,0 +1,17 @@
# 2026-10-11
## AI 学习助手悬浮按钮重设计
- 文件:`src/views/components/TrainingAiSidebar.vue`(收起态 `.ai-fab`)
- 原状:50px 纯图标圆形蓝球(MagicStick 图标),不够显眼
- 新设计:3D 黏土风 SVG 小助手(渐变头部、深蓝面罩发光眼、天线顶珠、星光点缀)+ 文字胶囊按钮(主标题"AI 学习助手" + 副标语"学习有疑问?随时问我")
- 动效:头像上下浮动(bob)、呼吸光环(pulse)、悬停上浮 + 小助手摆动(wiggle);含 prefers-reduced-motion 降级
- 尺寸:头像 44px,胶囊整体约 56px 高,固定右下角不遮挡内容;纯 SVG 内联无图片资源
### 追加:遮挡修复
- 用户反馈悬浮胶囊会遮挡底部「保存/Step 01-04/下一步」导航栏(step-navigation 为普通文档流,滚到底部即被压住)
- 方案:滚动方向感知自动隐藏 —— `.ai-fab` 增加 `is-hidden` 状态;window scroll 监听(rAF 节流,阈值 6px),向下滚动且 scrollY>40 时 translateY(150%)+opacity:0+pointer-events:none,向上滚动滑出;含 reduced-motion 兼容
### 追加:扇形展开交互
- 用户要求默认只展示头像,悬停时像扇子一样展开文字
- 实现:待机态按钮为 52px 圆形(padding 4px + 头像 44px),透明背景 + 圆形投影;渐变胶囊底色移到 `.ai-fab::before` 用 opacity 淡入(渐变本身不可 transition);文字用 max-width 0→180px + margin-left 0→10px + translateX(-10px)→0 展开,悬停/:focus-visible 均触发;滚动隐藏逻辑保留
- 调速:展开放慢至 0.55s cubic-bezier(0.22,1,0.36,1) ease-out(文字 opacity 延迟 0.08s),收合 0.32s ease-in 稍快回收;利用"transition 定义在目标态"机制区分展开/收合速度

@ -1,15 +1,60 @@
<template> <template>
<aside :class="['training-ai-sidebar', { 'is-collapsed': sidebarCollapsed }]"> <aside :class="['training-ai-sidebar', { 'is-collapsed': sidebarCollapsed }]">
<!-- 收起态:右下角圆形悬浮球 --> <!-- 收起态:右下角 3D 助手形象 + 文字胶囊悬浮按钮 -->
<button <button
v-if="sidebarCollapsed" v-if="sidebarCollapsed"
class="ai-fab" :class="['ai-fab', { 'is-hidden': fabHidden }]"
type="button" type="button"
aria-label="展开 AI 助学与助评" aria-label="展开 AI 学习助手"
title="AI 助学助手" title="AI 学习助手"
@click="sidebarCollapsed = false" @click="sidebarCollapsed = false"
> >
<el-icon class="ai-fab__icon"><MagicStick /></el-icon> <span class="ai-fab__mascot" aria-hidden="true">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="aiFabBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8ab6ff" />
<stop offset="0.55" stop-color="#3b82f6" />
<stop offset="1" stop-color="#1d4ed8" />
</linearGradient>
<linearGradient id="aiFabVisor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1e3a8a" />
<stop offset="1" stop-color="#152757" />
</linearGradient>
<linearGradient id="aiFabTip" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fde68a" />
<stop offset="1" stop-color="#f59e0b" />
</linearGradient>
</defs>
<!-- 地面投影 -->
<ellipse cx="32" cy="59" rx="15" ry="3" fill="#1e3a8a" opacity="0.2" />
<!-- 天线 + 顶珠 -->
<line x1="32" y1="13" x2="32" y2="7" stroke="#1e40af" stroke-width="2.4" stroke-linecap="round" />
<circle cx="32" cy="5.5" r="3.2" fill="url(#aiFabTip)" />
<!-- 侧耳 -->
<rect x="7" y="28" width="7" height="12" rx="3.5" fill="#1e40af" />
<rect x="50" y="28" width="7" height="12" rx="3.5" fill="#1e40af" />
<!-- 3D 黏土头部 -->
<ellipse cx="32" cy="34" rx="20" ry="19" fill="url(#aiFabBody)" />
<!-- 高光 -->
<ellipse cx="24" cy="21" rx="7.5" ry="3.6" fill="#ffffff" opacity="0.35" transform="rotate(-16 24 21)" />
<!-- 显示器面罩 -->
<rect x="17" y="25" width="30" height="15" rx="7.5" fill="url(#aiFabVisor)" />
<!-- 发光眼睛 -->
<circle cx="26.5" cy="32.5" r="2.7" fill="#7dd3fc" />
<circle cx="37.5" cy="32.5" r="2.7" fill="#7dd3fc" />
<circle cx="27.3" cy="31.7" r="0.9" fill="#ffffff" />
<circle cx="38.3" cy="31.7" r="0.9" fill="#ffffff" />
<!-- 微笑 -->
<path d="M29 36.5 Q32 39 35 36.5" stroke="#7dd3fc" stroke-width="1.6" fill="none" stroke-linecap="round" />
<!-- 星光点缀 -->
<path d="M50 12 C50.7 15.3 53.4 16.3 56 17 C53.4 17.7 50.7 18.7 50 22 C49.3 18.7 46.6 17.7 44 17 C46.6 16.3 49.3 15.3 50 12 Z" fill="#fbbf24" />
</svg>
</span>
<span class="ai-fab__label">
<strong>AI 学习助手</strong>
<em>学习有疑问?随时问我</em>
</span>
</button> </button>
<!-- 展开态 --> <!-- 展开态 -->
@ -219,6 +264,25 @@ const sidebarCollapsed = ref(true);
const activePanel = ref("study"); const activePanel = ref("study");
const chatStreamRef = ref(null); const chatStreamRef = ref(null);
const assistInputRef = ref(null); const assistInputRef = ref(null);
/* 悬浮按钮滚动方向感知:向下滚动时隐藏(避免遮挡底部导航/内容),向上滚动时滑出 */
const fabHidden = ref(false);
let lastScrollY = 0;
let fabScrollTicking = false;
function handleWindowScroll() {
if (fabScrollTicking) return;
fabScrollTicking = true;
requestAnimationFrame(() => {
fabScrollTicking = false;
const y = window.scrollY || 0;
const delta = y - lastScrollY;
if (Math.abs(delta) < 6) return;
fabHidden.value = delta > 0 && y > 40;
lastScrollY = y;
});
}
onMounted(() => window.addEventListener("scroll", handleWindowScroll, { passive: true }));
onBeforeUnmount(() => window.removeEventListener("scroll", handleWindowScroll));
/** 输入框最大高度(约 6 行):到此停止增高,内容在框内滚动(DeepSeek 式) */ /** 输入框最大高度(约 6 行):到此停止增高,内容在框内滚动(DeepSeek 式) */
const ASSIST_INPUT_MAX_HEIGHT = 168; const ASSIST_INPUT_MAX_HEIGHT = 168;
function autoGrowAssist() { function autoGrowAssist() {
@ -410,29 +474,159 @@ function plainText(value) {
width: auto; width: auto;
} }
/* ===== 收起态:右下角圆形悬浮球 ===== */ /* ===== 收起态:待机仅 3D 头像,悬停像扇子一样展开文字 ===== */
.ai-fab { .ai-fab {
position: fixed; position: fixed;
right: 24px; right: 24px;
bottom: 28px; bottom: 28px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; padding: 4px;
width: 50px; border: 1px solid rgba(255, 255, 255, 0);
height: 50px; border-radius: 999px;
border: 0;
border-radius: 50%;
color: #ffffff; color: #ffffff;
background: var(--ai-accent); background: transparent;
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.32); box-shadow: 0 8px 22px rgba(37, 99, 235, 0.32);
font-family: inherit;
text-align: left;
cursor: pointer; cursor: pointer;
transition: background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; transition: transform 0.22s ease, box-shadow 0.22s ease, opacity 0.22s ease,
border-color 0.25s ease, padding 0.28s ease;
}
/* 胶囊底色:默认透明,悬停时淡入,避免渐变背景无法过渡的问题 */
.ai-fab::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 55%, #1d4ed8 100%);
opacity: 0;
transition: opacity 0.2s ease;
}
/* 展开态:悬停 / 键盘聚焦(慢速展开,带 ease-out 回弹感) */
.ai-fab:hover:not(.is-hidden),
.ai-fab:focus-visible:not(.is-hidden) {
padding: 4px 20px 4px 4px;
border-color: rgba(255, 255, 255, 0.55);
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.46);
transition: padding 0.5s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.4s ease, box-shadow 0.35s ease,
transform 0.22s ease, opacity 0.22s ease;
}
.ai-fab:hover:not(.is-hidden)::before,
.ai-fab:focus-visible:not(.is-hidden)::before {
opacity: 1;
transition: opacity 0.45s ease;
}
/* 向下滚动时自动隐藏:滑出视口底部,完全不遮挡内容 */
.ai-fab.is-hidden {
transform: translateY(150%);
opacity: 0;
pointer-events: none;
}
.ai-fab:hover:not(.is-hidden) {
transform: translateY(-3px);
}
.ai-fab:active { transform: translateY(-1px); }
/* 3D 助手头像:浅色圆底衬托 SVG 形象,缓慢上下浮动 */
.ai-fab__mascot {
position: relative;
z-index: 1;
display: inline-flex;
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
background: radial-gradient(circle at 30% 25%, #ffffff 0%, #dbeafe 62%, #bfdbfe 100%);
box-shadow: inset 0 -2px 5px rgba(30, 58, 138, 0.18), 0 2px 6px rgba(15, 23, 42, 0.22);
animation: mascot-bob 3.2s ease-in-out infinite;
}
.ai-fab__mascot svg {
display: block;
width: 100%;
height: 100%;
}
/* 呼吸光环:吸引注意但不刺眼 */
.ai-fab__mascot::before {
content: "";
position: absolute;
inset: -4px;
border: 2px solid rgba(147, 197, 253, 0.75);
border-radius: 50%;
animation: mascot-pulse 2.4s ease-out infinite;
}
/* 文字描述:默认折叠在头像后方,悬停时像扇子一样横向展开 */
.ai-fab__label {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1px;
max-width: 0;
margin-left: 0;
overflow: hidden;
white-space: nowrap;
opacity: 0;
transform: translateX(-10px) scale(0.96);
transform-origin: left center;
/* 收合速度(离开悬停时稍快回收) */
transition: max-width 0.32s ease-in, margin-left 0.32s ease-in, opacity 0.2s ease-in,
transform 0.32s ease-in;
strong {
color: #ffffff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
line-height: 18px;
}
em {
color: rgba(255, 255, 255, 0.78);
font-size: 11px;
font-style: normal;
line-height: 15px;
}
}
.ai-fab:hover:not(.is-hidden) .ai-fab__label,
.ai-fab:focus-visible:not(.is-hidden) .ai-fab__label {
max-width: 180px;
margin-left: 10px;
opacity: 1;
transform: translateX(0) scale(1);
/* 展开速度(放慢 + ease-out,扇子徐徐展开) */
transition: max-width 0.55s cubic-bezier(0.22, 1, 0.36, 1),
margin-left 0.55s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease-out 0.08s,
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}
/* 悬停时小助手挥手式摆动 */
.ai-fab:hover .ai-fab__mascot svg { animation: mascot-wiggle 0.55s ease; }
@keyframes mascot-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes mascot-pulse {
0% { transform: scale(0.85); opacity: 0.9; }
70%, 100% { transform: scale(1.35); opacity: 0; }
}
@keyframes mascot-wiggle {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-7deg); }
60% { transform: rotate(6deg); }
} }
.ai-fab__icon { font-size: 23px; color: #ffffff; } @media (prefers-reduced-motion: reduce) {
.ai-fab:hover { .ai-fab:not(.is-hidden),
background: var(--ai-accent-deep); .ai-fab:hover:not(.is-hidden) { transform: none; }
box-shadow: 0 10px 28px rgba(37, 99, 235, 0.4); .ai-fab__mascot,
transform: translateY(-2px); .ai-fab__mascot::before,
.ai-fab:hover .ai-fab__mascot svg { animation: none; }
} }
/* ===== 展开态骨架:白底卡片,撑满 fixed 根 ===== */ /* ===== 展开态骨架:白底卡片,撑满 fixed 根 ===== */

Loading…
Cancel
Save