4 Commits

Author SHA1 Message Date
jiang 20ca410e0a 新增 TurnList 组件,优化消息渲染逻辑
Build Push and Deploy / docker-image (push) Successful in 1m19s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-03 17:49:39 +08:00
jiang 06a3f32d2d 重构组件,优化性能并移除不必要的属性;撤销滚动条修改; 2026-06-03 16:58:10 +08:00
jiang fa3e6b6e84 输入框状态剥离,避免受长信息列表渲染影响;覆写滚动条状态动作,不再强制拉到最底 2026-06-03 15:01:24 +08:00
jiang 888132a60f 统一时间时区请求 2026-06-03 11:17:27 +08:00
7 changed files with 343 additions and 95 deletions
+41 -17
View File
@@ -28,44 +28,65 @@ import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import type { AgentModel } from "@/lib/chatStream"; import type { AgentModel } from "@/lib/chatStream";
export type AgentComposerHandle = {
focus: () => void;
clear: () => void;
append: (text: string) => void;
setValue: (value: string) => void;
getValue: () => string;
};
type AgentComposerProps = { type AgentComposerProps = {
input: string;
inputRef: React.RefObject<HTMLInputElement | null>;
isHydrating?: boolean; isHydrating?: boolean;
isStreaming: boolean; isStreaming: boolean;
isListening: boolean; isListening: boolean;
isSttSupported: boolean; isSttSupported: boolean;
presets: string[]; presets: string[];
onInputChange: (value: string) => void; onSend: (prompt: string) => void;
onSend: () => void;
onAbort: () => void; onAbort: () => void;
onStartListening: () => void; onStartListening: () => void;
onStopListening: () => void; onStopListening: () => void;
onPresetSelect: (prompt: string) => void;
selectedModel: AgentModel; selectedModel: AgentModel;
onModelChange: (model: AgentModel) => void; onModelChange: (model: AgentModel) => void;
}; };
export const AgentComposer = ({ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
input,
inputRef,
isHydrating = false, isHydrating = false,
isStreaming, isStreaming,
isListening, isListening,
isSttSupported, isSttSupported,
presets, presets,
onInputChange,
onSend, onSend,
onAbort, onAbort,
onStartListening, onStartListening,
onStopListening, onStopListening,
onPresetSelect,
selectedModel, selectedModel,
onModelChange, onModelChange,
}: AgentComposerProps) => { }, ref) {
const theme = useTheme(); const theme = useTheme();
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const [input, setInput] = React.useState("");
const [isPresetOpen, setIsPresetOpen] = React.useState(false); const [isPresetOpen, setIsPresetOpen] = React.useState(false);
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
React.useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => setInput(""),
append: (text: string) => setInput((prev) => prev + text),
setValue: (value: string) => setInput(value),
getValue: () => input,
}),
[input],
);
const handleSend = React.useCallback(() => {
const prompt = input.trim();
if (!prompt || isStreaming || isHydrating) return;
setInput("");
onSend(prompt);
}, [input, isHydrating, isStreaming, onSend]);
return ( return (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}> <Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
@@ -121,8 +142,11 @@ export const AgentComposer = ({
size="medium" size="medium"
clickable clickable
onClick={() => { onClick={() => {
onPresetSelect(prompt); setInput(prompt);
setIsPresetOpen(false); setIsPresetOpen(false);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}} }}
sx={{ sx={{
height: 32, height: 32,
@@ -165,11 +189,11 @@ export const AgentComposer = ({
<TextField <TextField
inputRef={inputRef} inputRef={inputRef}
value={input} value={input}
onChange={(event) => onInputChange(event.target.value)} onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
onSend(); handleSend();
} }
}} }}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."} placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
@@ -362,7 +386,7 @@ export const AgentComposer = ({
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> <motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton <IconButton
disabled={!canSend} disabled={!canSend}
onClick={onSend} onClick={handleSend}
aria-label="发送" aria-label="发送"
size="small" size="small"
sx={{ sx={{
@@ -397,4 +421,4 @@ export const AgentComposer = ({
</Box> </Box>
</Box> </Box>
); );
}; });
+15 -1
View File
@@ -85,7 +85,12 @@ const formatToolTitle = (item: ChatProgress) => {
return item.title; return item.title;
}; };
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => { type AgentProgressTimelineProps = {
progress: ChatProgress[];
isAborted?: boolean;
};
const AgentProgressTimelineInner = ({ progress, isAborted }: AgentProgressTimelineProps) => {
const theme = useTheme(); const theme = useTheme();
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
@@ -356,3 +361,12 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
</Box> </Box>
); );
}; };
export const AgentProgressTimeline = React.memo(
AgentProgressTimelineInner,
(prevProps, nextProps) =>
prevProps.progress === nextProps.progress &&
prevProps.isAborted === nextProps.isAborted,
);
AgentProgressTimeline.displayName = "AgentProgressTimeline";
+11 -5
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import React from "react"; import React, { useMemo } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
@@ -85,15 +85,21 @@ export const AgentTurn = React.memo(
const [editDraft, setEditDraft] = React.useState(message.content); const [editDraft, setEditDraft] = React.useState(message.content);
const rootMessageId = message.branchRootId ?? message.id; const rootMessageId = message.branchRootId ?? message.id;
const parsedAssistantSections = const parsedAssistantSections = useMemo(
() =>
!isUser && !isErrorMessage !isUser && !isErrorMessage
? parseAssistantMessageSections(message.content) ? parseAssistantMessageSections(message.content)
: null; : null,
[isErrorMessage, isUser, message.content],
);
const answerContent = parsedAssistantSections?.answer ?? message.content; const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] = const contentSegments: ContentSegment[] = useMemo(
() =>
!isUser && !isErrorMessage !isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments ? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }]; : [{ type: "text", content: answerContent }],
[answerContent, isErrorMessage, isUser],
);
if (isUser) { if (isUser) {
return ( return (
@@ -0,0 +1,97 @@
/* eslint-disable @next/next/no-img-element */
import "@testing-library/jest-dom";
import React from "react";
import { render } from "@testing-library/react";
import { AgentWorkspace } from "./AgentWorkspace";
import type { Message } from "./GlobalChatbox.types";
const renderCounts = new Map<string, number>();
jest.mock("next/image", () => ({
__esModule: true,
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt ?? ""} />,
}));
jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: {
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
},
}));
jest.mock("./GlobalChatbox.parts", () => ({
TypingIndicator: () => <div>typing</div>,
}));
jest.mock("./AgentTurn", () => ({
AgentTurn: ({ message }: { message: Message }) => {
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
},
}));
describe("AgentWorkspace", () => {
const defaultProps = {
branchGroups: [],
branchTransition: null,
bottomRef: { current: null },
speakingMessageId: null,
speechState: "idle" as const,
onSpeak: jest.fn(),
onPauseSpeech: jest.fn(),
onResumeSpeech: jest.fn(),
onStopSpeech: jest.fn(),
isTtsSupported: false,
onRegenerate: jest.fn(),
onEditResubmit: jest.fn(),
onCycleBranch: jest.fn(),
};
beforeEach(() => {
renderCounts.clear();
});
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
const userMessage: Message = {
id: "user-1",
role: "user",
content: "question",
};
const assistantHistoryMessage: Message = {
id: "assistant-1",
role: "assistant",
content: "stable answer",
};
const streamingMessage: Message = {
id: "assistant-2",
role: "assistant",
content: "partial",
};
const { rerender } = render(
<AgentWorkspace
{...defaultProps}
isStreaming
messages={[userMessage, assistantHistoryMessage, streamingMessage]}
/>,
);
const updatedStreamingMessage: Message = {
...streamingMessage,
content: "partial with more tokens",
};
rerender(
<AgentWorkspace
{...defaultProps}
isStreaming
messages={[userMessage, assistantHistoryMessage, updatedStreamingMessage]}
/>,
);
expect(renderCounts.get("user-1")).toBe(1);
expect(renderCounts.get("assistant-1")).toBe(1);
expect(renderCounts.get("assistant-2")).toBe(2);
});
});
+142 -33
View File
@@ -13,6 +13,7 @@ import { AgentTurn } from "./AgentTurn";
import { TypingIndicator } from "./GlobalChatbox.parts"; import { TypingIndicator } from "./GlobalChatbox.parts";
import type { import type {
BranchGroup, BranchGroup,
BranchState,
BranchTransition, BranchTransition,
Message, Message,
SpeechState, SpeechState,
@@ -36,6 +37,96 @@ type AgentWorkspaceProps = {
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
}; };
type TurnListProps = {
messages: Message[];
branchGroups: BranchGroup[];
speakingMessageId: string | null;
speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
onRegenerate: () => void;
onEditResubmit: (messageId: string, newContent: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
};
const sameMessages = (left: Message[], right: Message[]) =>
left.length === right.length &&
left.every((message, index) => message === right[index]);
const TurnListInner = ({
messages,
branchGroups,
speakingMessageId,
speechState,
onSpeak,
onPauseSpeech,
onResumeSpeech,
onStopSpeech,
isTtsSupported,
onRegenerate,
onEditResubmit,
onCycleBranch,
}: TurnListProps) => {
const branchStateByRootId = React.useMemo(() => {
const next = new Map<string, BranchState>();
branchGroups.forEach((group) => {
if (group.branches.length > 1) {
next.set(group.rootMessageId, {
activeIndex: group.activeIndex,
total: group.branches.length,
});
}
});
return next;
}, [branchGroups]);
return (
<>
{messages.map((message) => {
const rootMessageId = message.branchRootId ?? message.id;
return (
<AgentTurn
key={rootMessageId}
message={message}
branchState={branchStateByRootId.get(rootMessageId)}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
/>
);
})}
</>
);
};
const TurnList = React.memo(
TurnListInner,
(prevProps, nextProps) =>
sameMessages(prevProps.messages, nextProps.messages) &&
prevProps.branchGroups === nextProps.branchGroups &&
prevProps.speakingMessageId === nextProps.speakingMessageId &&
prevProps.speechState === nextProps.speechState &&
prevProps.onSpeak === nextProps.onSpeak &&
prevProps.onPauseSpeech === nextProps.onPauseSpeech &&
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
prevProps.onStopSpeech === nextProps.onStopSpeech &&
prevProps.isTtsSupported === nextProps.isTtsSupported &&
prevProps.onRegenerate === nextProps.onRegenerate &&
prevProps.onEditResubmit === nextProps.onEditResubmit &&
prevProps.onCycleBranch === nextProps.onCycleBranch,
);
TurnList.displayName = "TurnList";
const EmptyState = () => { const EmptyState = () => {
const theme = useTheme(); const theme = useTheme();
const capabilities = [ const capabilities = [
@@ -182,37 +273,12 @@ export const AgentWorkspace = ({
const transitionMessages = branchTransition const transitionMessages = branchTransition
? messages.slice(branchTransition.parentCount) ? messages.slice(branchTransition.parentCount)
: []; : [];
const streamingMessage =
const renderTurn = (message: Message) => { !branchTransition && isStreaming && messages.at(-1)?.role === "assistant"
const rootMessageId = message.branchRootId ?? message.id; ? messages.at(-1)
const branchGroup = branchGroups.find( : undefined;
(group) => group.rootMessageId === rootMessageId, const historyMessages =
); streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages;
return (
<AgentTurn
key={rootMessageId}
message={message}
branchState={
branchGroup && branchGroup.branches.length > 1
? {
activeIndex: branchGroup.activeIndex,
total: branchGroup.branches.length,
}
: undefined
}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
/>
);
};
return ( return (
<Box <Box
@@ -232,7 +298,37 @@ export const AgentWorkspace = ({
{messages.length > 0 ? ( {messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{stableMessages.map(renderTurn)} <TurnList
messages={historyMessages}
branchGroups={branchGroups}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
/>
{streamingMessage ? (
<TurnList
messages={[streamingMessage]}
branchGroups={branchGroups}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
/>
) : null}
{branchTransition ? ( {branchTransition ? (
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false} mode="wait">
@@ -244,7 +340,20 @@ export const AgentWorkspace = ({
transition={{ duration: 0.18, ease: "easeOut" }} transition={{ duration: 0.18, ease: "easeOut" }}
style={{ display: "flex", flexDirection: "column", gap: 16 }} style={{ display: "flex", flexDirection: "column", gap: 16 }}
> >
{transitionMessages.map(renderTurn)} <TurnList
messages={transitionMessages}
branchGroups={branchGroups}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
/>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
) : null} ) : null}
+22 -30
View File
@@ -10,7 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
import type { AgentModel } from "@/lib/chatStream"; import type { AgentModel } from "@/lib/chatStream";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { AgentComposer } from "./AgentComposer"; import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader"; import { AgentHeader } from "./AgentHeader";
import { AgentHistoryPanel } from "./AgentHistoryPanel"; import { AgentHistoryPanel } from "./AgentHistoryPanel";
import { AgentWorkspace } from "./AgentWorkspace"; import { AgentWorkspace } from "./AgentWorkspace";
@@ -22,7 +22,6 @@ import { useAgentChatSession } from "./hooks/useAgentChatSession";
import { useAgentToolActions } from "./hooks/useAgentToolActions"; import { useAgentToolActions } from "./hooks/useAgentToolActions";
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [input, setInput] = useState("");
const [width, setWidth] = useState(520); const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false);
@@ -31,7 +30,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
); );
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null);
const hasResetForOpenRef = useRef(false); const hasResetForOpenRef = useRef(false);
const theme = useTheme(); const theme = useTheme();
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -47,7 +46,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
} = useSpeechSynthesis(); } = useSpeechSynthesis();
const handleSpeechResult = useCallback((text: string) => { const handleSpeechResult = useCallback((text: string) => {
setInput((prev) => prev + text); composerRef.current?.append(text);
}, []); }, []);
const { const {
@@ -83,9 +82,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
getModel: () => selectedModel, getModel: () => selectedModel,
}); });
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
bottomRef.current?.scrollIntoView({ behavior });
}, []);
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); scrollToBottom(isStreaming ? "auto" : "smooth");
}, [messages, isStreaming]); }, [isStreaming, messages, scrollToBottom]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -97,37 +100,29 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
createSession(); createSession();
setInput(""); composerRef.current?.clear();
setIsHistoryOpen(false); setIsHistoryOpen(false);
inputRef.current?.focus(); composerRef.current?.focus();
bottomRef.current?.scrollIntoView({ behavior: "auto" }); scrollToBottom("auto");
}, 0); }, 0);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [createSession, isHydrating, open]); }, [createSession, isHydrating, open, scrollToBottom]);
const handleSend = useCallback(() => { const handleSend = useCallback((prompt: string) => {
const prompt = input.trim(); if (isStreaming) return;
if (!prompt || isStreaming) return;
setInput("");
void sendPrompt(prompt); void sendPrompt(prompt);
}, [input, isStreaming, sendPrompt]); }, [isStreaming, sendPrompt]);
const handlePresetPromptSelect = useCallback((prompt: string) => {
setInput(prompt);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, []);
const handleNewConversation = useCallback(() => { const handleNewConversation = useCallback(() => {
handleStopSpeech(); handleStopSpeech();
stopListening(); stopListening();
createSession(); createSession();
setInput(""); composerRef.current?.clear();
window.setTimeout(() => { window.setTimeout(() => {
inputRef.current?.focus(); composerRef.current?.focus();
scrollToBottom("auto");
}, 0); }, 0);
}, [createSession, handleStopSpeech, stopListening]); }, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
const handleHistoryToggle = useCallback(() => { const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev); setIsHistoryOpen((prev) => !prev);
@@ -135,7 +130,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const handleSelectSession = useCallback( const handleSelectSession = useCallback(
(storageSessionId: string) => { (storageSessionId: string) => {
setInput(""); composerRef.current?.clear();
void switchSession(storageSessionId); void switchSession(storageSessionId);
}, },
[switchSession], [switchSession],
@@ -334,19 +329,16 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
/> />
<AgentComposer <AgentComposer
input={input} ref={composerRef}
inputRef={inputRef}
isHydrating={isHydrating} isHydrating={isHydrating}
isStreaming={isStreaming} isStreaming={isStreaming}
isListening={isListening} isListening={isListening}
isSttSupported={isSttSupported} isSttSupported={isSttSupported}
presets={PRESET_PROMPTS} presets={PRESET_PROMPTS}
onInputChange={setInput}
onSend={handleSend} onSend={handleSend}
onAbort={abort} onAbort={abort}
onStartListening={startListening} onStartListening={startListening}
onStopListening={stopListening} onStopListening={stopListening}
onPresetSelect={handlePresetPromptSelect}
selectedModel={selectedModel} selectedModel={selectedModel}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
/> />
@@ -623,7 +623,10 @@ const Timeline: React.FC<TimelineProps> = ({
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变 // 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
const calculationDate = selectedDate; const calculationDate = selectedDate;
const calculationTime = currentTime; const calculationTime = currentTime;
const calculationDateStr = calculationDate.toISOString().split("T")[0]; const calculationDateTime = currentTimeToDate(
calculationDate,
calculationTime
);
setIsCalculating(true); setIsCalculating(true);
// 显示处理中的通知 // 显示处理中的通知
@@ -635,8 +638,7 @@ const Timeline: React.FC<TimelineProps> = ({
try { try {
const body = { const body = {
name: NETWORK_NAME, name: NETWORK_NAME,
simulation_date: calculationDateStr, // YYYY-MM-DD start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
start_time: `${formatTime(calculationTime)}:00`, // HH:MM:00
duration: calculatedInterval, duration: calculatedInterval,
}; };
@@ -651,7 +653,9 @@ const Timeline: React.FC<TimelineProps> = ({
}, },
); );
if (response.ok) { const result = await response.json().catch(() => null);
if (response.ok && result?.status === "success") {
open?.({ open?.({
type: "success", type: "success",
message: "重新计算成功", message: "重新计算成功",
@@ -660,9 +664,11 @@ const Timeline: React.FC<TimelineProps> = ({
clearCacheAndRefetch(calculationDate, calculationTime); clearCacheAndRefetch(calculationDate, calculationTime);
setForceStyleAutoApplyVersion?.((prev) => prev + 1); setForceStyleAutoApplyVersion?.((prev) => prev + 1);
} else { } else {
const errorMessage =
result?.detail || result?.message || "重新计算失败";
open?.({ open?.({
type: "error", type: "error",
message: "重新计算失败", message: errorMessage,
}); });
} }
} catch (error) { } catch (error) {