Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default function App() {
.getState()
.instances.find((i) => i.id === id);
if (instance && isBusy(instance.status)) {
showToast(t("sidebar.shutdownBusy"));
showToast(t("sidebar.shutdownBusy"), "warning");
return;
}
shuttingDownRef.current.add(id);
Expand Down Expand Up @@ -112,7 +112,7 @@ export default function App() {
if (shuttingDownRef.current.has(selectedInstanceId)) {
shuttingDownRef.current.delete(selectedInstanceId);
} else {
showToast(t("eventStream.instanceNotFound"));
showToast(t("eventStream.instanceNotFound"), "error");
}
navigate("/");
}
Expand Down
4 changes: 2 additions & 2 deletions src/web/src/components/InstanceView/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export function Composer({
}));
})
.catch((err) => {
showToast(t("eventStream.imageProcessFailed"));
showToast(t("eventStream.imageProcessFailed"), "error");
console.error("Image process failed:", err);
});
},
Expand All @@ -162,7 +162,7 @@ export function Composer({
}));
})
.catch((err) => {
showToast(t("eventStream.imageProcessFailed"));
showToast(t("eventStream.imageProcessFailed"), "error");
console.error("Image process failed:", err);
});
e.target.value = "";
Expand Down
2 changes: 1 addition & 1 deletion src/web/src/components/InstanceView/useConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function useConversation(
defaultValue: msg.payload.message,
})
: msg.payload.message;
showToast(localizedMsg);
showToast(localizedMsg, "error");
break;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/web/src/components/InstanceView/useReEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function useReEdit(
const currentDraft =
useDrafts.getState().drafts.get(instanceId) ?? EMPTY_DRAFT;
if (currentDraft.text.trim() || currentDraft.images.length > 0) {
showToast(t("reEdit.draftNotEmpty"));
showToast(t("reEdit.draftNotEmpty"), "warning");
return;
}

Expand Down
100 changes: 75 additions & 25 deletions src/web/src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,131 @@
// 轻量 Toast 提示组件
// 轻量 Toast 提示组件(支持分级)
// macOS 26 Notification 风格:浮动毛玻璃面板 + 滑入/淡出动画
// 级别通过左侧彩色图标区分,保持整体 Liquid Glass 材质统一

import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Info,
CheckCircle,
AlertTriangle,
AlertCircle,
type LucideIcon,
} from "lucide-react";

export type ToastLevel = "info" | "success" | "warning" | "error";

export interface ToastItem {
id: number;
message: string;
level: ToastLevel;
/** 动画阶段:entering → visible → exiting */
phase: "entering" | "visible" | "exiting";
}

let toastId = 0;
let listeners: Array<(toast: ToastItem) => void> = [];

/** 全局触发一条 toast */
export function showToast(message: string) {
const item: ToastItem = { id: ++toastId, message, phase: "entering" };
/** 全局触发一条 toast,level 默认 info */
export function showToast(message: string, level: ToastLevel = "info") {
const item: ToastItem = { id: ++toastId, message, level, phase: "entering" };
listeners.forEach((fn) => fn(item));
}

const TOAST_ENTER_MS = 16; // 一帧后切换到 visible 触发 CSS transition
const TOAST_DURATION_MS = 3000;
const TOAST_EXIT_MS = 300;

// 不同级别的持续时间
const DURATION_MAP: Record<ToastLevel, number> = {
info: 3000,
success: 3000,
warning: 4000,
error: 5000,
};

// 级别对应的图标组件
const ICON_MAP: Record<ToastLevel, LucideIcon> = {
info: Info,
success: CheckCircle,
warning: AlertTriangle,
error: AlertCircle,
};

// 级别对应的颜色 CSS 变量
const COLOR_MAP: Record<ToastLevel, string> = {
info: "var(--toast-icon-info)",
success: "var(--toast-icon-success)",
warning: "var(--toast-icon-warning)",
error: "var(--toast-icon-error)",
};

/** Toast 容器,放在 App 顶层即可 */
export function ToastContainer() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());

useEffect(() => {
const handler = (item: ToastItem) => {
setToasts((prev) => [...prev, item]);

// 入场:下一帧切到 visible 触发 transition
setTimeout(() => {
const enterTimer = setTimeout(() => {
timersRef.current.delete(enterTimer);
setToasts((prev) =>
prev.map((t) => (t.id === item.id ? { ...t, phase: "visible" } : t)),
);
}, TOAST_ENTER_MS);
timersRef.current.add(enterTimer);

// 到时间后进入退出动画
setTimeout(() => {
const duration = DURATION_MAP[item.level];
const exitTimer = setTimeout(() => {
timersRef.current.delete(exitTimer);
setToasts((prev) =>
prev.map((t) => (t.id === item.id ? { ...t, phase: "exiting" } : t)),
);

// 动画结束后真正移除
setTimeout(() => {
const removeTimer = setTimeout(() => {
timersRef.current.delete(removeTimer);
setToasts((prev) => prev.filter((t) => t.id !== item.id));
}, TOAST_EXIT_MS);
}, TOAST_DURATION_MS);
timersRef.current.add(removeTimer);
}, duration);
timersRef.current.add(exitTimer);
};

listeners.push(handler);
return () => {
listeners = listeners.filter((fn) => fn !== handler);
// 清理所有未完成的 timer,避免卸载后 setState
timersRef.current.forEach((t) => clearTimeout(t));
timersRef.current.clear();
};
}, []);

if (toasts.length === 0) return null;

return (
<div className="fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 pointer-events-none">
{toasts.map((item) => (
<div
key={item.id}
className="pointer-events-auto rounded-full bg-[var(--panel-bg)] backdrop-blur-[30px] border-[0.5px] border-[var(--panel-border)] shadow-[var(--panel-shadow)] px-5 py-2.5 text-[13px] leading-[16px] text-[var(--label-primary)] select-none transition-all duration-300 ease-out"
style={
item.phase === "entering"
? { opacity: 0, transform: "translateY(-8px)" }
: item.phase === "exiting"
? { opacity: 0, transform: "translateY(-4px)" }
: { opacity: 1, transform: "translateY(0)" }
}
>
{item.message}
</div>
))}
{toasts.map((item) => {
const Icon = ICON_MAP[item.level];
const color = COLOR_MAP[item.level];
return (
<div
key={item.id}
className="pointer-events-auto flex items-center gap-2 rounded-full bg-[var(--panel-bg)] backdrop-blur-[30px] border-[0.5px] border-[var(--panel-border)] shadow-[var(--panel-shadow)] px-4 py-2.5 text-[13px] leading-[16px] text-[var(--label-primary)] select-none transition-all duration-300 ease-out"
style={
item.phase === "entering"
? { opacity: 0, transform: "translateY(-8px)" }
: item.phase === "exiting"
? { opacity: 0, transform: "translateY(-4px)" }
: { opacity: 1, transform: "translateY(0)" }
}
>
<Icon size={14} className="shrink-0" style={{ color }} />
<span>{item.message}</span>
</div>
);
})}
</div>
);
}
18 changes: 18 additions & 0 deletions src/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@
--badge-bg: rgba(0, 0, 0, 0.5);
--badge-bg-hover: rgba(0, 0, 0, 0.7);
--badge-text: #ffffff;

/* Toast 分级图标色(macOS 26 Accent Colors - Opaque) */
--toast-icon-info: #0091ff;
--toast-icon-success: #30d158;
--toast-icon-warning: #ff9230;
--toast-icon-error: #ff4245;
}

@media (prefers-color-scheme: dark) {
Expand Down Expand Up @@ -161,6 +167,12 @@
#1e2e1e 75%,
#1a1a2e 100%
);

/* Toast 分级图标色(macOS 26 Accent Colors - Vibrant,暗色下更鲜亮) */
--toast-icon-info: #0a99ff;
--toast-icon-success: #3bdb63;
--toast-icon-warning: #ff9e33;
--toast-icon-error: #ff4747;
}
}

Expand Down Expand Up @@ -188,6 +200,12 @@
--diff-context-text: var(--label-secondary);
--btn-solid: #282828;
--btn-solid-hover: #3d3d3d;

/* Toast 分级图标色(macOS 26 Accent Colors - Vibrant) */
--toast-icon-info: #0a99ff;
--toast-icon-success: #3bdb63;
--toast-icon-warning: #ff9e33;
--toast-icon-error: #ff4747;
}

/* ========================================
Expand Down