Merge pull request #505 from Open-Less/fix/saved-toast-position

fix(ui): 统一 SavedToast 弹框位置与进出方向
This commit is contained in:
TRIP authored and GitHub committed 2026-05-20 16:29:09 +08:00
commit e8f8ac0f59
3 files changed
+69 -38

No files matched your search

+62 -30
View File
@@ -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<CSSProperties, 'top' | 'right' | 'left' | 'bottom' | 'position'>;
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 (
<div role={failed ? 'alert' : 'status'} style={style}>
{message}
<style>{`
@keyframes ol-toast-pop {
from { opacity: 0; transform: translateY(-6px) scale(.96); filter: blur(4px); }
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}
`}</style>
</div>
<AnimatePresence>
{internalVisible && (
<motion.div
role={failed ? 'alert' : 'status'}
initial={{ ...offscreen, filter: 'blur(8px)' }}
animate={{ opacity: 1, x: 0, y: 0, filter: 'blur(0px)' }}
exit={{ ...offscreen, filter: 'blur(4px)' }}
transition={{ type: 'spring', damping: 20, stiffness: 260 }}
style={style}
>
{failed ? '⚠️' : '✓'} {message}
</motion.div>
)}
</AnimatePresence>
);
}
@@ -204,11 +204,14 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
section 标题都不会跟着内容一起飘。 */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
{/* "已保存"toast 在内容区右上角;right:54 避开 28×28 关闭按钮 + 12px gap。
弹窗内用 absolute 锚内容区、从上方滑入 / 滑回 —— 外层 overflow:hidden
会把它裁在面板顶边,读感即"从屏幕外上方来、回上方去"。
CredentialField 等通过 emitSaved 发事件,useSavedToastListener 接收。 */}
<SavedToast
saveState={savedToast.state}
message={savedToast.message}
offsetStyle={{ top: 16, right: 54 }}
slideFrom="top"
offsetStyle={{ position: 'absolute', top: 16, right: 54 }}
/>
<button
onClick={onClose}
+3 -7
View File
@@ -536,13 +536,9 @@ export function Style() {
)}
/>
{/* 视口锚定(position: fixed)—— 编辑器展开后滚动到下方时仍可见。
放在 bottom-right 避免压在「导入 ZIP」按钮上挡文字。 */}
<SavedToast
saveState={saveState}
message={saveMessage}
offsetStyle={{ position: 'fixed', bottom: 32, right: 32, top: 'auto' }}
/>
{/* 控制台卡右上角锚定 —— 与「风格市场 / 刷新 / 导入 ZIP」按钮同区;
淡蓝 pill 只闪现 0.8s,不长期遮挡按钮。 */}
<SavedToast saveState={saveState} message={saveMessage} />
{marketplaceOpen && (
<MarketplaceModal