diff --git a/openless-all/app/src/components/SavedToast.tsx b/openless-all/app/src/components/SavedToast.tsx index 18439a0c..ce88a87c 100644 --- a/openless-all/app/src/components/SavedToast.tsx +++ b/openless-all/app/src/components/SavedToast.tsx @@ -1,56 +1,88 @@ -// SavedToast.tsx — 控制台卡右上角的"正在保存 / 已保存 / 失败"小 pill。 -// 父级 scroll wrapper(FloatingShell main 区)已设 position:relative, -// 此 pill 用 absolute 锚到右上角,避免在页面顶部撑成一条难看的长横幅。 - +import { AnimatePresence, motion } from 'framer-motion'; import type { CSSProperties } from 'react'; +import { useEffect, useState } from 'react'; export type SaveToastState = 'idle' | 'saving' | 'saved' | 'failed'; +// 弹框入场 / 退场方向 —— 始终"从哪来,回哪去"(同一方向进出)。 +// 'right':从屏幕右侧滑入、再滑回右侧 —— 页面级 toast(风格包 / 翻译 / 划词…)。 +// 'top' :从屏幕上方滑入、再滑回上方 —— 设置弹窗内的 toast。 +export type ToastSlideFrom = 'right' | 'top'; + interface SavedToastProps { saveState: SaveToastState; message: string; - /** 覆盖默认 position:absolute、top:16 right:16 偏移。 - * Style 页传 position:'fixed' 把 toast 锚到视口右上角,编辑器展开后向下滚也能看见; - * SettingsModal 用默认 absolute 锚在模态内容右上角。 */ offsetStyle?: Pick; + slideFrom?: ToastSlideFrom; } -export function SavedToast({ saveState, message, offsetStyle }: SavedToastProps) { - if (saveState === 'idle') return null; +export function SavedToast({ saveState, message, offsetStyle, slideFrom = 'right' }: SavedToastProps) { + // 维护内部状态,使通知可以自己倒计时关闭(即使用户父组件的 timer 长于 0.8s) + const [internalVisible, setInternalVisible] = useState(false); + + useEffect(() => { + if (saveState !== 'idle') { + setInternalVisible(true); + // 满足用户要求:弹出后约 0.8 秒自动收回 + const timer = window.setTimeout(() => setInternalVisible(false), 800); + return () => window.clearTimeout(timer); + } + setInternalVisible(false); + }, [saveState, message]); + const failed = saveState === 'failed'; + + // 统一停靠右上角 —— 跟「风格市场 / 刷新 / 导入 ZIP」这排页头按钮同区。 + // position:fixed 锚视口:滑入 / 滑出都贴着屏幕边走,不会在页面里撑出滚动条。 + // 设置弹窗自行传 offsetStyle 覆盖成 absolute(锚到弹窗内容区右上角)。 const style: CSSProperties = { - position: 'absolute', - top: 16, - right: 16, + position: 'fixed', + top: 20, + right: 28, ...offsetStyle, - // 必须高于所有 modal(backdrop zIndex 50);失败 toast 决不能被 modal 盖住,否则用户看不到错因。 - zIndex: 9999, - padding: '5px 12px', + zIndex: 99999, + padding: '4px 11px', borderRadius: 999, border: failed ? '0.5px solid rgba(239,68,68,0.22)' : '0.5px solid rgba(37,99,235,0.16)', - background: failed ? 'rgba(239,68,68,0.10)' : 'rgba(37,99,235,0.10)', - color: failed ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)', + background: failed ? 'rgba(254,242,242,0.92)' : 'rgba(239,244,255,0.92)', + color: failed ? '#dc2626' : '#2563eb', fontSize: 11.5, - fontWeight: 500, - lineHeight: 1.4, - boxShadow: '0 4px 12px -4px rgba(15,17,22,0.18), 0 0 0 0.5px rgba(0,0,0,0.04)', + fontWeight: 600, + lineHeight: 1.5, + boxShadow: failed + ? '0 4px 12px -8px rgba(239,68,68,.28)' + : '0 4px 12px -8px rgba(37,99,235,.26)', backdropFilter: 'blur(12px) saturate(160%)', WebkitBackdropFilter: 'blur(12px) saturate(160%)', pointerEvents: 'none', - animation: 'ol-toast-pop 0.22s var(--ol-motion-spring)', whiteSpace: 'nowrap', + display: 'flex', + alignItems: 'center', + gap: 6, }; + + // "从哪来,回哪去":入场起点 == 退场终点,方向由 slideFrom 决定。 + // 两个分支都写全 x / y —— motion variant 保持完整,避免另一轴落到隐式默认值。 + const offscreen = slideFrom === 'top' + ? { opacity: 0, x: 0, y: '-220%' } + : { opacity: 0, x: '120%', y: 0 }; + return ( -
- {message} - -
+ + {internalVisible && ( + + {failed ? '⚠️' : '✓'} {message} + + )} + ); } diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 0245f64f..0ae8963a 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -204,11 +204,14 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett section 标题都不会跟着内容一起飘。 */}
{/* "已保存"toast 在内容区右上角;right:54 避开 28×28 关闭按钮 + 12px gap。 + 弹窗内用 absolute 锚内容区、从上方滑入 / 滑回 —— 外层 overflow:hidden + 会把它裁在面板顶边,读感即"从屏幕外上方来、回上方去"。 CredentialField 等通过 emitSaved 发事件,useSavedToastListener 接收。 */}