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:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Dialog } from "../ui/Dialog";
|
import { Dialog } from "../ui/Dialog";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { useToast } from "../ui/Toast";
|
import { useToast } from "../ui/Toast";
|
||||||
@@ -15,19 +15,31 @@ import {
|
|||||||
|
|
||||||
// 每用户 JARVIS 设置:名字 / 人设 / (高级)自带豆包配置。
|
// 每用户 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 toast = useToast();
|
||||||
const [cfg, setCfg] = useState<JarvisConfig | null>(null);
|
const [cfg, setCfg] = useState<JarvisConfig | null>(null);
|
||||||
|
const toastRef = useRef(toast); // effect 里用 ref 取最新 toast,避免它进依赖
|
||||||
|
toastRef.current = toast;
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [advanced, setAdvanced] = useState(false);
|
const [advanced, setAdvanced] = useState(false);
|
||||||
|
|
||||||
|
// 只在"打开"时拉一次。toast 不能进依赖——它一变就会重拉配置、把用户正在填的内容覆盖掉。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setCfg(null);
|
setCfg(null);
|
||||||
getMyJarvis()
|
getMyJarvis()
|
||||||
.then(setCfg)
|
.then(setCfg)
|
||||||
.catch((e) => toast.push("error", (e as Error).message));
|
.catch((e) => toastRef.current.push("error", (e as Error).message));
|
||||||
}, [open, toast]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const set = (k: keyof JarvisConfig, v: string) => setCfg((c) => (c ? { ...c, [k]: v } : c));
|
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_resource_id: cfg.tts_resource_id,
|
||||||
tts_voice_type: cfg.tts_voice_type,
|
tts_voice_type: cfg.tts_voice_type,
|
||||||
});
|
});
|
||||||
toast.push("success", "已保存,下次说话即生效");
|
toast.push("success", `已保存:${cfg.name.trim() || "JARVIS"} · 下次说话即生效`);
|
||||||
|
onSaved?.();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.push("error", (e as Error).message);
|
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="text-slate-500">加载中…</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<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">
|
<label className="block">
|
||||||
<span className="text-xs text-slate-400">助手名字</span>
|
<span className="text-xs text-slate-400">助手名字</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -108,12 +108,14 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
|
|||||||
const onNavigateRef = useRef(onNavigate);
|
const onNavigateRef = useRef(onNavigate);
|
||||||
onNavigateRef.current = onNavigate;
|
onNavigateRef.current = onNavigate;
|
||||||
|
|
||||||
// 助手名:挂载即拉一次(未登录/失败保持默认)。
|
// 助手名:挂载即拉一次(未登录/失败保持默认)。设置里保存后也会调它重拉——
|
||||||
useEffect(() => {
|
// 否则改完名字这里还挂着旧的,用户根本看不出配置生效了没。
|
||||||
|
const refreshName = useCallback(() => {
|
||||||
getMyJarvis()
|
getMyJarvis()
|
||||||
.then((j) => setName(j.name || "JARVIS"))
|
.then((j) => setName(j.name || "JARVIS"))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(refreshName, [refreshName]);
|
||||||
|
|
||||||
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
|
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
|
||||||
const ensureClient = useCallback((): VoiceClient => {
|
const ensureClient = useCallback((): VoiceClient => {
|
||||||
@@ -439,7 +441,7 @@ export function VoiceDock({ onTask, onNavigate }: Props) {
|
|||||||
{hint}
|
{hint}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<JarvisSettings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<JarvisSettings open={settingsOpen} onClose={() => setSettingsOpen(false)} onSaved={refreshName} />
|
||||||
{fullscreen && (
|
{fullscreen && (
|
||||||
<JarvisHud
|
<JarvisHud
|
||||||
name={name}
|
name={name}
|
||||||
|
|||||||
@@ -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 { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
|
||||||
import { cn } from "./cn";
|
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));
|
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 (
|
return (
|
||||||
<Ctx.Provider value={{ push }}>
|
<Ctx.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
|
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
|
||||||
{toasts.map((t) => {
|
{toasts.map((t) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user