|
1 | 1 | <script> |
2 | 2 | import {onMount, tick} from 'svelte'; |
| 3 | + import {scale, fade} from 'svelte/transition'; |
3 | 4 | import {Logger} from './utils/logger.js'; |
4 | 5 | import {SETTINGS} from './utils/settings.js'; |
5 | 6 | import AppController from './app/app-controller.js'; |
6 | 7 |
|
7 | | -
|
8 | 8 | // Import logo images for inline packaging |
9 | 9 | import logo40w from '../public/assets/logo-40w.png'; |
10 | 10 | import logo80w from '../public/assets/logo-80w.png'; |
11 | 11 | import logo120w from '../public/assets/logo-120w.png'; |
| 12 | + import logoFullRes from '../public/assets/logo.png'; |
12 | 13 |
|
13 | 14 | let showModal = false; |
14 | 15 | let isAssistantInitialized = false; |
15 | 16 | let appInstance = null; |
16 | 17 | let fab; |
17 | 18 | let modalContent; |
18 | 19 | let aiContainerElement; |
| 20 | + let isButtonClicked = false; |
| 21 | + let rippleActive = false; |
| 22 | + let fabPosition = {x: 0, y: 0}; // FAB按钮中心坐标 |
| 23 | + let modalOrigin = '50% 50%'; // Modal的transform-origin |
19 | 24 |
|
20 | 25 |
|
21 | 26 | // FAB 拖拽逻辑 |
|
116 | 121 | }; |
117 | 122 | } |
118 | 123 |
|
| 124 | + function calculateFabCenter() { |
| 125 | + if (!fab) return {x: 0, y: 0}; |
| 126 | + const rect = fab.getBoundingClientRect(); |
| 127 | + return { |
| 128 | + x: rect.left + rect.width / 2, |
| 129 | + y: rect.top + rect.height / 2 |
| 130 | + }; |
| 131 | + } |
| 132 | +
|
| 133 | + function updateModalOrigin() { |
| 134 | + const {x, y} = fabPosition; |
| 135 | + const vw = window.innerWidth; |
| 136 | + const vh = window.innerHeight; |
| 137 | + const originX = (x / vw) * 100; |
| 138 | + const originY = (y / vh) * 100; |
| 139 | + modalOrigin = `${originX}% ${originY}%`; |
| 140 | + } |
| 141 | +
|
119 | 142 | async function handleFabClick() { |
120 | 143 | if (wasDragged) return; |
121 | 144 |
|
| 145 | + // 按钮点击动画效果 |
| 146 | + isButtonClicked = true; |
| 147 | + rippleActive = true; |
| 148 | +
|
| 149 | + // 300ms后重置按钮状态 |
| 150 | + setTimeout(() => { |
| 151 | + isButtonClicked = false; |
| 152 | + rippleActive = false; |
| 153 | + }, 300); |
| 154 | +
|
| 155 | + // 计算FAB按钮位置 |
| 156 | + fabPosition = calculateFabCenter(); |
| 157 | + updateModalOrigin(); |
| 158 | +
|
122 | 159 | showModal = true; |
123 | 160 | if (!isAssistantInitialized) { |
124 | 161 | await tick(); |
|
143 | 180 | } |
144 | 181 | } |
145 | 182 |
|
| 183 | + function dynamicScale(node, {duration = 180, start = 0.8, origin = '50% 50%'}) { |
| 184 | + return { |
| 185 | + duration, |
| 186 | + css: (t) => { |
| 187 | + const scale = start + (1 - start) * t; |
| 188 | + const opacity = t; |
| 189 | + return ` |
| 190 | + transform: scale(${scale}); |
| 191 | + transform-origin: ${origin}; |
| 192 | + opacity: ${opacity}; |
| 193 | + `; |
| 194 | + } |
| 195 | + }; |
| 196 | + } |
| 197 | +
|
146 | 198 | // Click outside to close logic |
147 | 199 | function handleClickOutside(event) { |
148 | 200 | if (fab && fab.contains(event.target)) { |
|
183 | 235 | } |
184 | 236 | } |
185 | 237 |
|
| 238 | + // 窗口大小变化时重新计算 |
| 239 | + $: if (typeof window !== 'undefined') { |
| 240 | + const handleResize = () => { |
| 241 | + if (showModal && fab) { |
| 242 | + fabPosition = calculateFabCenter(); |
| 243 | + updateModalOrigin(); |
| 244 | + } |
| 245 | + }; |
| 246 | +
|
| 247 | + if (showModal) { |
| 248 | + window.addEventListener('resize', handleResize); |
| 249 | + } else { |
| 250 | + window.removeEventListener('resize', handleResize); |
| 251 | + } |
| 252 | + } |
| 253 | +
|
186 | 254 | onMount(() => { |
187 | 255 | // 修复:只在需要时添加事件监听器,避免全局污染 |
188 | 256 | return () => { |
|
195 | 263 | <button |
196 | 264 | bind:this={fab} |
197 | 265 | id="ai-assistant-fab" |
| 266 | + class:fab-clicked={isButtonClicked} |
| 267 | + class:fab-ripple={rippleActive} |
| 268 | + |
198 | 269 | on:mousedown={handleMouseDown} |
199 | 270 | on:click={handleFabClick} |
200 | 271 | > |
|
206 | 277 | </button> |
207 | 278 |
|
208 | 279 | {#if showModal} |
209 | | - <div class="ai-modal-content" bind:this={modalContent} use:draggable> |
| 280 | + <div class="ai-modal-content" bind:this={modalContent} use:draggable |
| 281 | + transition:dynamicScale={{duration: 180, start: 0.8, origin: modalOrigin}}> |
210 | 282 | <button class="ai-modal-close-btn" on:click={() => { showModal = false; }}>×</button> |
211 | | - <div id="ai-container" bind:this={aiContainerElement}></div> |
| 283 | + <div id="ai-container" bind:this={aiContainerElement}> |
| 284 | + {#if !isAssistantInitialized} |
| 285 | + <div class="simple-loading" transition:fade={{duration: 300}}> |
| 286 | + <img src={logoFullRes} alt="Loading" class="loading-logo"/> |
| 287 | + </div> |
| 288 | + {:else if false} |
| 289 | + <div class="skeleton-container"> |
| 290 | + <div class="skeleton-header"></div> |
| 291 | + <div class="skeleton-message"></div> |
| 292 | + <div class="skeleton-message short"></div> |
| 293 | + <div class="skeleton-input"></div> |
| 294 | + </div> |
| 295 | + {/if} |
| 296 | + </div> |
212 | 297 | </div> |
213 | 298 | {/if} |
214 | 299 |
|
|
250 | 335 | transform: scale(0.98); |
251 | 336 | } |
252 | 337 |
|
| 338 | + /* FAB 动画状态 */ |
| 339 | + #ai-assistant-fab.fab-clicked { |
| 340 | + transform: scale(0.95); |
| 341 | + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); |
| 342 | + transition: all 0.15s ease-out; |
| 343 | + } |
| 344 | +
|
| 345 | + #ai-assistant-fab.fab-ripple::after { |
| 346 | + content: ''; |
| 347 | + position: absolute; |
| 348 | + top: 50%; |
| 349 | + left: 50%; |
| 350 | + width: 0; |
| 351 | + height: 0; |
| 352 | + border-radius: 50%; |
| 353 | + background: rgba(255, 255, 255, 0.3); |
| 354 | + transform: translate(-50%, -50%); |
| 355 | + animation: ripple 0.6s ease-out; |
| 356 | + } |
| 357 | +
|
| 358 | + @keyframes ripple { |
| 359 | + to { |
| 360 | + width: 120px; |
| 361 | + height: 120px; |
| 362 | + opacity: 0; |
| 363 | + } |
| 364 | + } |
| 365 | +
|
| 366 | + /* 移除了旋转动画效果 */ |
| 367 | +
|
253 | 368 | /* Modal Styles */ |
254 | 369 | .ai-modal-content { |
255 | 370 | position: fixed; |
|
309 | 424 | display: flex; |
310 | 425 | flex-direction: column; |
311 | 426 | overflow: hidden; /* Important to contain children */ |
| 427 | + position: relative; /* 为loading-splash提供定位上下文 */ |
312 | 428 | } |
313 | 429 |
|
314 | 430 | .ai-modal-close-btn svg, |
|
317 | 433 | height: 100%; |
318 | 434 | } |
319 | 435 |
|
| 436 | + /* 简单加载动画样式 */ |
| 437 | + .simple-loading { |
| 438 | + position: absolute; |
| 439 | + top: 0; |
| 440 | + left: 0; |
| 441 | + width: 100%; |
| 442 | + height: 100%; |
| 443 | + display: flex; |
| 444 | + justify-content: center; |
| 445 | + align-items: center; |
| 446 | + z-index: 10001; |
| 447 | + } |
| 448 | +
|
| 449 | + .loading-logo { |
| 450 | + width: 80px; |
| 451 | + height: 80px; |
| 452 | + opacity: 0.7; |
| 453 | + animation: breathing 1s ease-in-out infinite; |
| 454 | + } |
| 455 | +
|
| 456 | + @keyframes breathing { |
| 457 | + 0%, 100% { |
| 458 | + transform: scale(1); |
| 459 | + opacity: 0.7; |
| 460 | + } |
| 461 | + 50% { |
| 462 | + transform: scale(1.1); |
| 463 | + opacity: 1; |
| 464 | + } |
| 465 | + } |
| 466 | +
|
| 467 | + /* 骨架屏样式 */ |
| 468 | + .skeleton-container { |
| 469 | + padding: 20px; |
| 470 | + width: 100%; |
| 471 | + height: 100%; |
| 472 | + } |
| 473 | +
|
| 474 | + .skeleton-header, |
| 475 | + .skeleton-message, |
| 476 | + .skeleton-input { |
| 477 | + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); |
| 478 | + background-size: 200% 100%; |
| 479 | + animation: skeletonLoading 1.5s infinite; |
| 480 | + border-radius: 4px; |
| 481 | + margin-bottom: 16px; |
| 482 | + } |
| 483 | +
|
| 484 | + .skeleton-header { |
| 485 | + height: 24px; |
| 486 | + width: 60%; |
| 487 | + } |
| 488 | +
|
| 489 | + .skeleton-message { |
| 490 | + height: 16px; |
| 491 | + width: 100%; |
| 492 | + } |
| 493 | +
|
| 494 | + .skeleton-message.short { |
| 495 | + width: 75%; |
| 496 | + } |
| 497 | +
|
| 498 | + .skeleton-input { |
| 499 | + height: 40px; |
| 500 | + width: 100%; |
| 501 | + margin-top: 20px; |
| 502 | + } |
| 503 | +
|
| 504 | + @keyframes skeletonLoading { |
| 505 | + 0% { |
| 506 | + background-position: -200% 0; |
| 507 | + } |
| 508 | + 100% { |
| 509 | + background-position: 200% 0; |
| 510 | + } |
| 511 | + } |
| 512 | +
|
| 513 | + /* 性能优化 */ |
| 514 | + #ai-assistant-fab, |
| 515 | + .ai-modal-content, |
| 516 | + .loading-splash { |
| 517 | + will-change: transform; |
| 518 | + } |
| 519 | +
|
| 520 | + /* 减少动画偏好支持 */ |
| 521 | + @media (prefers-reduced-motion: reduce) { |
| 522 | + #ai-assistant-fab, |
| 523 | + .ai-modal-content, |
| 524 | + .loading-logo { |
| 525 | + animation: none !important; |
| 526 | + transition: none !important; |
| 527 | + } |
| 528 | + } |
| 529 | +
|
320 | 530 |
|
321 | 531 | </style> |
0 commit comments