fix(ui): 设置弹窗狂闪+输入被清空(toast context 每次渲染造新对象) + 配置生效可见

【弹窗闪/填着填着没了】根因在 Toast Provider:`value={{ push }}` 每次渲染都造
新对象,而全项目有 7 处把 useToast() 结果放进 useEffect 依赖。于是**任意一条
toast 弹出**都会让这些 effect 重跑——JarvisSettings 因此重新拉取配置、把用户
正在填的内容覆盖掉;若拉取本身失败又会 push 错误 toast → 无限循环狂闪。

修:Provider 的 context value 用 useMemo 稳定(一处修好,7 处受益);
JarvisSettings 的加载 effect 只依赖 open,toast 走 ref 取用(纵深防御)。

【不确定配置是否生效】改完名字语音坞还挂着旧名(只在挂载时拉一次),
用户完全看不出改动生效——这本身就是 bug。
修:设置保存后回调 onSaved → 语音坞立刻重拉名字;弹窗顶部加「当前生效」摘要
(助手名/人设是否自定义/语音走自己的还是系统配置)+ 一句验证方法。

live 验证:①填入「星期五」后触发 toast,输入内容不再被清空 ②保存后落库正确
③按语音提示词问「你是谁」→ 答「我是星期五,你的私人语音助手」

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-25 15:54:01 +08:00
parent a16229573b
commit 7e0393bf62
3 changed files with 39 additions and 10 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Dialog } from "../ui/Dialog";
import { Button } from "../ui/Button";
import { useToast } from "../ui/Toast";
@@ -15,19 +15,31 @@ import {
// 每用户 JARVIS 设置:名字 / 人设 / (高级)自带豆包配置。
// 名字与人设归用户自己;豆包配置齐全则语音走用户的账号,否则走系统兜底。
export function JarvisSettings({ open, onClose }: { open: boolean; onClose: () => void }) {
export function JarvisSettings({
open,
onClose,
onSaved,
}: {
open: boolean;
onClose: () => void;
onSaved?: () => void; // 保存成功后通知外层刷新(名字要立刻反映到语音坞/HUD,否则看不出生效)
}) {
const toast = useToast();
const [cfg, setCfg] = useState<JarvisConfig | null>(null);
const toastRef = useRef(toast); // effect 里用 ref 取最新 toast,避免它进依赖
toastRef.current = toast;
const [saving, setSaving] = useState(false);
const [advanced, setAdvanced] = useState(false);
// 只在"打开"时拉一次。toast 不能进依赖——它一变就会重拉配置、把用户正在填的内容覆盖掉。
useEffect(() => {
if (!open) return;
setCfg(null);
getMyJarvis()
.then(setCfg)
.catch((e) => toast.push("error", (e as Error).message));
}, [open, toast]);
.catch((e) => toastRef.current.push("error", (e as Error).message));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const set = (k: keyof JarvisConfig, v: string) => setCfg((c) => (c ? { ...c, [k]: v } : c));
@@ -43,7 +55,8 @@ export function JarvisSettings({ open, onClose }: { open: boolean; onClose: () =
tts_resource_id: cfg.tts_resource_id,
tts_voice_type: cfg.tts_voice_type,
});
toast.push("success", "已保存,下次说话即生效");
toast.push("success", `已保存:${cfg.name.trim() || "JARVIS"} · 下次说话即生效`);
onSaved?.();
onClose();
} catch (e) {
toast.push("error", (e as Error).message);
@@ -72,6 +85,15 @@ export function JarvisSettings({ open, onClose }: { open: boolean; onClose: () =
<div className="text-slate-500"></div>
) : (
<div className="space-y-4">
<div className="rounded-md border border-line bg-ink-900/50 px-3 py-2 text-[11px] leading-relaxed text-slate-500">
<b className="text-slate-300">{cfg.name.trim() || "JARVIS"}</b>
{" · "} <b className="text-slate-300">{cfg.persona.trim() ? "已自定义" : "默认平和"}</b>
{" · "} <b className="text-slate-300">{cfg.has_own_voice ? "你自己的豆包配置" : "系统配置"}</b>
<span className="mt-0.5 block">
</span>
</div>
<label className="block">
<span className="text-xs text-slate-400"></span>
<input
@@ -108,12 +108,14 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
const onNavigateRef = useRef(onNavigate);
onNavigateRef.current = onNavigate;
// 助手名:挂载即拉一次(未登录/失败保持默认)。
useEffect(() => {
// 助手名:挂载即拉一次(未登录/失败保持默认)。设置里保存后也会调它重拉——
// 否则改完名字这里还挂着旧的,用户根本看不出配置生效了没。
const refreshName = useCallback(() => {
getMyJarvis()
.then((j) => setName(j.name || "JARVIS"))
.catch(() => {});
}, []);
useEffect(refreshName, [refreshName]);
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
const ensureClient = useCallback((): VoiceClient => {
@@ -439,7 +441,7 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
{hint}
</span>
</div>
<JarvisSettings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<JarvisSettings open={settingsOpen} onClose={() => setSettingsOpen(false)} onSaved={refreshName} />
{fullscreen && (
<JarvisHud
name={name}
+7 -2
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react";
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
import { cn } from "./cn";
@@ -39,8 +39,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
// 必须 useMemo`value={{ push }}` 每次渲染都造新对象,凡是把 useToast() 结果放进
// useEffect 依赖的组件(有 7 处)都会在**任意一条 toast 弹出时**重跑 effect。
// JarvisSettings 因此边填边被重新拉取覆盖;拉取失败还会 push 错误 toast → 无限循环狂闪。
const value = useMemo(() => ({ push }), [push]);
return (
<Ctx.Provider value={{ push }}>
<Ctx.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
{toasts.map((t) => {