@langgraph-js/ui 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import remarkGfm from "remark-gfm";
7
7
  import { Highlight, themes } from "prism-react-renderer";
8
8
  import { MessagesBox } from "./MessageBox";
9
9
 
10
- const TOOL_COLORS = ["border-red-400", "border-blue-400", "border-green-500", "border-yellow-400", "border-purple-400", "border-pink-400", "border-indigo-400"];
10
+ const TOOL_COLORS = ["bg-white", "bg-white", "bg-white", "bg-white", "bg-white", "bg-white", "bg-white"];
11
11
 
12
12
  interface MessageToolProps {
13
13
  message: ToolMessage & RenderMessage;
@@ -30,21 +30,21 @@ const getToolColorClass = (tool_name: string) => {
30
30
  const MessageTool: React.FC<MessageToolProps> = ({ message, client, getMessageContent, formatTokens, isCollapsed, onToggleCollapse }) => {
31
31
  const { getToolUIRender } = useChat();
32
32
  const render = getToolUIRender(message.name!);
33
- const borderColorClass = getToolColorClass(message.name!);
33
+ const bgColorClass = getToolColorClass(message.name!);
34
34
  return (
35
35
  <div className="flex flex-col w-full">
36
36
  {render ? (
37
37
  (render(message) as JSX.Element)
38
38
  ) : (
39
- <div className={`flex flex-col w-full bg-white rounded-lg shadow-sm border-2 ${borderColorClass} overflow-hidden`}>
40
- <div className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-100 transition-colors" onClick={onToggleCollapse}>
41
- <div className="text-sm font-medium text-gray-700" onClick={() => console.log(message)}>
39
+ <div className={`flex flex-col w-full ${bgColorClass} rounded-2xl overflow-hidden`}>
40
+ <div className="flex items-center justify-between px-5 py-3 cursor-pointer hover:bg-black/5 transition-colors" onClick={onToggleCollapse}>
41
+ <div className="text-xs font-medium text-gray-600" onClick={() => console.log(message)}>
42
42
  {message.node_name} | {message.name}
43
43
  </div>
44
44
  </div>
45
45
 
46
46
  {!isCollapsed && (
47
- <div className="flex flex-col gap-4 p-4 border-t border-gray-100">
47
+ <div className="flex flex-col gap-4 px-5 pb-4">
48
48
  <Previewer content={message.tool_input || ""} />
49
49
  <Previewer content={getMessageContent(message.content)} />
50
50
  <UsageMetadata
@@ -59,7 +59,7 @@ const MessageTool: React.FC<MessageToolProps> = ({ message, client, getMessageCo
59
59
  </div>
60
60
  )}
61
61
  {message.sub_agent_messages && (
62
- <div className="flex flex-col pl-4 py-2 border-l border-gray-300">
62
+ <div className="flex flex-col pl-6 py-3 ml-4 border-l-2 border-gray-200">
63
63
  <MessagesBox renderMessages={message.sub_agent_messages} collapsedTools={[]} toggleToolCollapse={(id) => {}} client={client} />
64
64
  </div>
65
65
  )}
@@ -86,22 +86,22 @@ const Previewer = ({ content }: { content: string }) => {
86
86
  return (
87
87
  <div className={`flex flex-col`}>
88
88
  <div className="flex gap-2 mb-2">
89
- <button onClick={copyToClipboard} className="px-2 py-1 text-xs font-medium text-gray-600 bg-green-100 rounded hover:bg-green-200 transition-colors">
89
+ <button onClick={copyToClipboard} className="px-3 py-1 text-xs font-medium text-gray-700 bg-white/60 rounded-lg hover:bg-white transition-colors">
90
90
  copy
91
91
  </button>
92
92
  {isJSON && (
93
- <button onClick={() => setJsonMode(!jsonMode)} className="px-2 py-1 text-xs font-medium text-gray-600 bg-orange-100 rounded hover:bg-orange-200 transition-colors">
93
+ <button onClick={() => setJsonMode(!jsonMode)} className="px-3 py-1 text-xs font-medium text-gray-700 bg-white/60 rounded-lg hover:bg-white transition-colors">
94
94
  json
95
95
  </button>
96
96
  )}
97
97
  {isMarkdown && (
98
- <button onClick={() => setMarkdownMode(!markdownMode)} className="px-2 py-1 text-xs font-medium text-gray-600 bg-blue-100 rounded hover:bg-blue-200 transition-colors">
98
+ <button onClick={() => setMarkdownMode(!markdownMode)} className="px-3 py-1 text-xs font-medium text-gray-700 bg-white/60 rounded-lg hover:bg-white transition-colors">
99
99
  markdown
100
100
  </button>
101
101
  )}
102
102
  </div>
103
103
 
104
- <div className="flex flex-col max-h-[300px] overflow-auto border border-gray-200 rounded p-2 w-full text-xs font-mono whitespace-pre-wrap">
104
+ <div className="flex flex-col max-h-[300px] overflow-auto bg-white/40 rounded-xl p-3 w-full text-xs font-mono whitespace-pre-wrap">
105
105
  {jsonMode && isJSON ? (
106
106
  <Highlight code={JSON.stringify(JSON.parse(content), null, 2)} language="json" theme={themes.oneLight}>
107
107
  {({ className, style, tokens, getLineProps, getTokenProps }) => (
@@ -3,6 +3,9 @@ interface UsageMetadataProps {
3
3
  input_tokens: number;
4
4
  output_tokens: number;
5
5
  total_tokens: number;
6
+ input_token_details?: {
7
+ cache_read?: number;
8
+ };
6
9
  }>;
7
10
  response_metadata?: {
8
11
  model_name?: string;
@@ -17,17 +20,18 @@ const formatTokens = (tokens: number) => {
17
20
  };
18
21
  export const UsageMetadata: React.FC<UsageMetadataProps> = ({ usage_metadata, spend_time, response_metadata, id, tool_call_id }) => {
19
22
  const speed = spend_time ? ((usage_metadata.output_tokens || 0) * 1000) / (spend_time || 1) : 0;
23
+ spend_time = spend_time && !isNaN(spend_time) ? spend_time : 0;
20
24
  return (
21
- <div className="flex items-center justify-between text-xs text-gray-500 mt-2">
25
+ <div className="flex items-center justify-between text-xs text-gray-400 mt-3">
22
26
  <div className="flex items-center gap-3">
23
27
  <TokenPanel usage_metadata={usage_metadata} />
24
- {spend_time && <span className="text-gray-500">{(spend_time / 1000).toFixed(2)}s</span>}
25
- {speed && <span className="text-gray-500">{speed.toFixed(2)} TPS</span>}
28
+ {!!spend_time && <span className="text-gray-400">{(spend_time / 1000).toFixed(2)}s</span>}
29
+ {!!speed && <span className="text-gray-400">{speed.toFixed(2)} TPS</span>}
26
30
  </div>
27
31
  <div className="flex items-center gap-2">
28
- {response_metadata?.model_name && <span className="text-gray-600">{response_metadata.model_name}</span>}
29
- {tool_call_id && <span className="text-gray-500">Tool: {tool_call_id}</span>}
30
- {id && <span className="text-gray-500">ID: {id}</span>}
32
+ {response_metadata?.model_name && <span className="text-gray-500">{response_metadata.model_name}</span>}
33
+ {tool_call_id && <span className="text-gray-400">Tool: {tool_call_id}</span>}
34
+ {id && <span className="text-gray-400">ID: {id}</span>}
31
35
  </div>
32
36
  </div>
33
37
  );
@@ -36,22 +40,49 @@ export const UsageMetadata: React.FC<UsageMetadataProps> = ({ usage_metadata, sp
36
40
  export const TokenPanel = ({ usage_metadata }: any) => {
37
41
  return (
38
42
  <div className="flex items-center gap-1">
39
- <span className="flex items-center gap-1 group relative">
40
- <span>📊</span>
41
- {formatTokens(usage_metadata.total_tokens || 0)}
42
- <div className="hidden group-hover:block absolute bottom-full ml-2 bg-gray-100 p-2 rounded text-xs shadow border">
43
- <div className="flex flex-col gap-1">
44
- <div className="flex flex-col gap-1">
45
- <span className="flex items-center gap-1">
46
- <span>📥</span>
47
- {formatTokens(usage_metadata.input_tokens || 0)}
48
- <div> {JSON.stringify(usage_metadata.prompt_tokens_details)}</div>
49
- </span>
50
- <span className="flex items-center gap-1">
51
- <span>📤</span>
52
- {formatTokens(usage_metadata.output_tokens || 0)}
53
- <div> {JSON.stringify(usage_metadata.completion_tokens_details)}</div>
54
- </span>
43
+ <span className="flex items-center gap-1.5 group relative">
44
+ <span className="text-xs">📊</span>
45
+ <span className="text-gray-400">{formatTokens(usage_metadata.total_tokens || 0)}</span>
46
+ <div className="hidden group-hover:block absolute bottom-full left-0 mb-2 bg-white/95 backdrop-blur-sm border border-gray-200 shadow-lg rounded-lg text-xs z-10 min-w-[280px]">
47
+ <div className="p-3">
48
+ <div className="text-sm font-medium text-gray-700 mb-3 border-b border-gray-100 pb-2">Token 使用详情</div>
49
+ <div className="space-y-3">
50
+ {/* 输入 Token */}
51
+ <div className="flex items-center justify-between">
52
+ <div className="flex items-center gap-2">
53
+ <span className="text-green-600">📥</span>
54
+ <span className="text-gray-700 font-medium">输入 Token</span>
55
+ </div>
56
+ <span className="text-gray-900 font-mono">{formatTokens(usage_metadata.input_tokens || 0)}</span>
57
+ </div>
58
+
59
+ {/* 缓存读取 */}
60
+ {!!usage_metadata.input_token_details?.cache_read && (
61
+ <div className="flex items-center justify-between ml-6">
62
+ <div className="flex items-center gap-2">
63
+ <span className="text-blue-600">💾</span>
64
+ <span className="text-gray-600 text-xs">缓存读取</span>
65
+ </div>
66
+ <span className="text-blue-700 font-mono text-xs">{formatTokens(usage_metadata.input_token_details.cache_read)}</span>
67
+ </div>
68
+ )}
69
+
70
+ {/* 输出 Token */}
71
+ <div className="flex items-center justify-between border-t border-gray-100 pt-3">
72
+ <div className="flex items-center gap-2">
73
+ <span className="text-blue-600">📤</span>
74
+ <span className="text-gray-700 font-medium">输出 Token</span>
75
+ </div>
76
+ <span className="text-gray-900 font-mono">{formatTokens(usage_metadata.output_tokens || 0)}</span>
77
+ </div>
78
+
79
+ {/* 额外详情 */}
80
+ {(usage_metadata.prompt_tokens_details || usage_metadata.completion_tokens_details) && (
81
+ <div className="border-t border-gray-100 pt-3 mt-3">
82
+ <div className="text-xs text-gray-500 mb-2">详细数据</div>
83
+ <div className="text-xs text-gray-400 bg-gray-50 p-2 rounded">输入详情: {JSON.stringify(usage_metadata)}</div>
84
+ </div>
85
+ )}
55
86
  </div>
56
87
  </div>
57
88
  </div>
@@ -17,6 +17,7 @@ interface ChatProviderProps {
17
17
  import { globalChatStore } from "../store";
18
18
  import { UnionStore, useUnionStore } from "@langgraph-js/sdk";
19
19
  import { useStore } from "@nanostores/react";
20
+ import { toast } from "../../sonner/toast";
20
21
  export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
21
22
  const store = useUnionStore(globalChatStore, useStore);
22
23
  useEffect(() => {
@@ -26,13 +27,26 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
26
27
  if (store.showHistory) {
27
28
  store.refreshHistoryList();
28
29
  }
29
- console.log(res);
30
+ // console.log(res);
31
+ toast.success("Hello, LangGraph!");
30
32
  })
31
33
  .catch((err) => {
32
34
  console.error(err);
33
- const agentName = prompt("Failed to initialize chat client: " + store.currentAgent + "\n请输入 agent 名称");
34
- localStorage.setItem("agent_name", agentName!);
35
- location.reload();
35
+ toast.error("请检查服务器配置: ", "初始化客户端失败," + store.currentAgent + "\n" + err, {
36
+ duration: 10000,
37
+ action: {
38
+ label: "去设置",
39
+ onClick: () => {
40
+ document.getElementById("setting-button")?.click();
41
+ setTimeout(() => {
42
+ document.getElementById("server-login-button")?.click();
43
+ }, 300);
44
+ },
45
+ },
46
+ });
47
+ // const agentName = prompt("Failed to initialize chat client: " + store.currentAgent + "\n请输入 agent 名称");
48
+ // localStorage.setItem("agent_name", agentName!);
49
+ // location.reload();
36
50
  });
37
51
  }, []);
38
52
 
@@ -2,3 +2,22 @@
2
2
  white-space: pre-wrap;
3
3
  overflow: hidden;
4
4
  }
5
+
6
+ /* Apple 风格的平滑滚动 */
7
+ .langgraph-chat-container ::-webkit-scrollbar {
8
+ width: 8px;
9
+ height: 8px;
10
+ }
11
+
12
+ .langgraph-chat-container ::-webkit-scrollbar-track {
13
+ background: transparent;
14
+ }
15
+
16
+ .langgraph-chat-container ::-webkit-scrollbar-thumb {
17
+ background: rgba(0, 0, 0, 0.1);
18
+ border-radius: 10px;
19
+ }
20
+
21
+ .langgraph-chat-container ::-webkit-scrollbar-thumb:hover {
22
+ background: rgba(0, 0, 0, 0.15);
23
+ }
@@ -42,13 +42,14 @@ export const memoryTool = createMemoryTool(db);
42
42
  const defaultHeaders = JSON.parse(localStorage.getItem("code") || "{}");
43
43
 
44
44
  export const globalChatStore = createChatStore(
45
- localStorage.getItem("agent_name") || "",
45
+ localStorage.getItem("defaultAgent") || "",
46
46
  {
47
47
  apiUrl: localStorage.getItem("apiUrl") || "http://localhost:8123",
48
48
  defaultHeaders,
49
49
  callerOptions: {
50
50
  // 携带 cookie 的写法
51
51
  fetch: F,
52
+ maxRetries: 1,
52
53
  },
53
54
  },
54
55
  {
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import "virtual:uno.css";
2
2
  import "@andypf/json-viewer/dist/iife/index.js";
3
3
  export { default as Chat } from "./chat/Chat";
4
+ export { Toaster, toast } from "./sonner";
@@ -0,0 +1,234 @@
1
+ import React, { useState, useEffect, useCallback, ChangeEvent } from "react";
2
+ import { toast } from "../sonner";
3
+
4
+ interface HeaderConfig {
5
+ key: string;
6
+ value: string;
7
+ }
8
+
9
+ interface LoginSettingsData {
10
+ headers: HeaderConfig[];
11
+ withCredentials: boolean;
12
+ apiUrl: string;
13
+ defaultAgent: string;
14
+ }
15
+
16
+ const initialLoginSettings: LoginSettingsData = {
17
+ headers: [{ key: "authorization", value: "" }],
18
+ withCredentials: false,
19
+ apiUrl: "",
20
+ defaultAgent: "assistant",
21
+ };
22
+
23
+ const LoginSettings: React.FC = () => {
24
+ const [formData, setFormData] = useState<LoginSettingsData>(() => {
25
+ try {
26
+ const storedHeaders = localStorage.getItem("code");
27
+ const storedWithCredentials = localStorage.getItem("withCredentials");
28
+ const storedApiUrl = localStorage.getItem("apiUrl");
29
+ const storedDefaultAgent = localStorage.getItem("defaultAgent");
30
+
31
+ let headers = initialLoginSettings.headers;
32
+ if (storedHeaders) {
33
+ const parsedHeaders = JSON.parse(storedHeaders);
34
+ // 检查是否是对象格式(旧格式),如果是则转换为数组格式
35
+ if (typeof parsedHeaders === "object" && !Array.isArray(parsedHeaders)) {
36
+ headers = Object.entries(parsedHeaders).map(([key, value]) => ({ key, value: value as string }));
37
+ } else if (Array.isArray(parsedHeaders)) {
38
+ headers = parsedHeaders;
39
+ }
40
+ }
41
+
42
+ return {
43
+ headers,
44
+ withCredentials: storedWithCredentials ? JSON.parse(storedWithCredentials) : initialLoginSettings.withCredentials,
45
+ apiUrl: storedApiUrl || initialLoginSettings.apiUrl,
46
+ defaultAgent: storedDefaultAgent || initialLoginSettings.defaultAgent,
47
+ };
48
+ } catch (error) {
49
+ console.error("Error reading login settings from localStorage:", error);
50
+ return initialLoginSettings;
51
+ }
52
+ });
53
+
54
+ useEffect(() => {
55
+ try {
56
+ localStorage.setItem("code", JSON.stringify(formData.headers));
57
+ localStorage.setItem("withCredentials", JSON.stringify(formData.withCredentials));
58
+ localStorage.setItem("apiUrl", formData.apiUrl);
59
+ localStorage.setItem("defaultAgent", formData.defaultAgent);
60
+ } catch (error) {
61
+ console.error("Error writing login settings to localStorage:", error);
62
+ }
63
+ }, [formData]);
64
+
65
+ const addHeader = useCallback(() => {
66
+ setFormData((prevData) => ({
67
+ ...prevData,
68
+ headers: [...prevData.headers, { key: "", value: "" }],
69
+ }));
70
+ }, []);
71
+
72
+ const removeHeader = useCallback((index: number) => {
73
+ setFormData((prevData) => ({
74
+ ...prevData,
75
+ headers: prevData.headers.filter((_, i) => i !== index),
76
+ }));
77
+ }, []);
78
+
79
+ const updateHeader = useCallback((index: number, field: "key" | "value", value: string) => {
80
+ setFormData((prevData) => {
81
+ const newHeaders = [...prevData.headers];
82
+ newHeaders[index][field] = value;
83
+ return { ...prevData, headers: newHeaders };
84
+ });
85
+ }, []);
86
+
87
+ const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
88
+ const { name, value, type, checked } = e.target;
89
+ setFormData((prevData) => ({
90
+ ...prevData,
91
+ [name]: type === "checkbox" ? checked : value,
92
+ }));
93
+ }, []);
94
+
95
+ const handleSelectChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
96
+ const { name, value } = e.target;
97
+ setFormData((prevData) => ({
98
+ ...prevData,
99
+ [name]: value,
100
+ }));
101
+ }, []);
102
+
103
+ const handleSave = () => {
104
+ // 保存逻辑已经通过 useEffect 处理了,这里可以添加其他保存后的操作,例如提示用户保存成功
105
+ toast.success("配置已保存!重启程序");
106
+ setTimeout(() => {
107
+ window.location.reload();
108
+ }, 500);
109
+ };
110
+
111
+ return (
112
+ <form className="space-y-6">
113
+ <div>
114
+ <label htmlFor="default-agent" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
115
+ 默认 Agent 名称
116
+ </label>
117
+ <input
118
+ type="text"
119
+ id="defaultAgent"
120
+ name="defaultAgent"
121
+ value={formData.defaultAgent}
122
+ onChange={handleInputChange}
123
+ placeholder="例如: assistant"
124
+ className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
125
+ />
126
+ </div>
127
+
128
+ <div>
129
+ <label htmlFor="api-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
130
+ API URL
131
+ </label>
132
+ <input
133
+ type="text"
134
+ id="apiUrl"
135
+ name="apiUrl"
136
+ value={formData.apiUrl}
137
+ onChange={handleInputChange}
138
+ placeholder="例如: http://localhost:8123"
139
+ className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
140
+ />
141
+ </div>
142
+
143
+ <div>
144
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">请求头配置</label>
145
+ <div className="space-y-3">
146
+ {formData.headers.map((header, index) => (
147
+ <div key={index} className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 shadow-sm">
148
+ <div className="grid grid-cols-2 gap-4 mb-3">
149
+ <div>
150
+ <input
151
+ type="text"
152
+ id={`header-key-${index}`}
153
+ value={header.key}
154
+ onChange={(e) => updateHeader(index, "key", e.target.value)}
155
+ placeholder="例如: authorization"
156
+ required
157
+ className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
158
+ />
159
+ </div>
160
+ <div>
161
+ <input
162
+ type="text"
163
+ id={`header-value-${index}`}
164
+ value={header.value}
165
+ onChange={(e) => updateHeader(index, "value", e.target.value)}
166
+ placeholder="例如: Bearer token;无则填 1"
167
+ required
168
+ className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
169
+ />
170
+ </div>
171
+ </div>
172
+ {index > 0 && (
173
+ <button
174
+ type="button"
175
+ onClick={() => removeHeader(index)}
176
+ className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-500 text-sm font-medium flex items-center space-x-1 transition-colors"
177
+ >
178
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
179
+ <path
180
+ strokeLinecap="round"
181
+ strokeLinejoin="round"
182
+ strokeWidth="2"
183
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
184
+ />
185
+ </svg>
186
+ <span>删除此请求头</span>
187
+ </button>
188
+ )}
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </div>
193
+
194
+ <div className="flex items-center space-x-3">
195
+ <input
196
+ type="checkbox"
197
+ id="withCredentials"
198
+ name="withCredentials"
199
+ checked={formData.withCredentials}
200
+ onChange={handleInputChange}
201
+ className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
202
+ />
203
+ <label htmlFor="withCredentials" className="text-sm text-gray-700 dark:text-gray-300">
204
+ 启用 withCredentials(跨域请求时发送 Cookie)
205
+ </label>
206
+ </div>
207
+
208
+ <div className="flex flex-col sm:flex-row gap-4 pt-4">
209
+ <button
210
+ type="button"
211
+ onClick={addHeader}
212
+ className="flex-1 px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors font-medium flex items-center justify-center space-x-2"
213
+ >
214
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
215
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
216
+ </svg>
217
+ <span>添加请求头</span>
218
+ </button>
219
+ <button
220
+ type="button"
221
+ onClick={handleSave}
222
+ className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium flex items-center justify-center space-x-2"
223
+ >
224
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
225
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
226
+ </svg>
227
+ <span>保存配置</span>
228
+ </button>
229
+ </div>
230
+ </form>
231
+ );
232
+ };
233
+
234
+ export default LoginSettings;
@@ -0,0 +1,42 @@
1
+ import React, { useState, useEffect, useCallback, ChangeEvent } from "react";
2
+
3
+ interface SettingFormBaseProps<T> {
4
+ settingKey: string;
5
+ initialData: T;
6
+ children: (data: T, handleChange: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void) => React.ReactNode;
7
+ }
8
+
9
+ function SettingFormBase<T extends Record<string, any>>({ settingKey, initialData, children }: SettingFormBaseProps<T>) {
10
+ const [formData, setFormData] = useState<T>(initialData);
11
+
12
+ useEffect(() => {
13
+ try {
14
+ const storedData = localStorage.getItem(settingKey);
15
+ if (storedData) {
16
+ setFormData(JSON.parse(storedData));
17
+ }
18
+ } catch (error) {
19
+ console.error(`Error reading from localStorage for key ${settingKey}:`, error);
20
+ }
21
+ }, [settingKey]);
22
+
23
+ useEffect(() => {
24
+ try {
25
+ localStorage.setItem(settingKey, JSON.stringify(formData));
26
+ } catch (error) {
27
+ console.error(`Error writing to localStorage for key ${settingKey}:`, error);
28
+ }
29
+ }, [formData, settingKey]);
30
+
31
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
32
+ const { name, value, type, checked } = e.target as HTMLInputElement;
33
+ setFormData((prevData) => ({
34
+ ...prevData,
35
+ [name]: type === "checkbox" ? checked : value,
36
+ }));
37
+ }, []);
38
+
39
+ return <>{children(formData, handleChange)}</>;
40
+ }
41
+
42
+ export default SettingFormBase;
@@ -0,0 +1,97 @@
1
+ import React, { useState, useEffect, ReactNode } from "react";
2
+ import LoginSettings from "./LoginSettings"; // 导入新的 LoginSettings 组件
3
+
4
+ interface SettingPanelProps {
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ tabs?: SettingTab[]; // 外部传入的 tabs,可选
8
+ }
9
+
10
+ export interface SettingTab {
11
+ id: string;
12
+ title: string;
13
+ component: ReactNode;
14
+ }
15
+
16
+ const SettingPanel: React.FC<SettingPanelProps> = ({ isOpen, onClose, tabs: externalTabs }) => {
17
+ // 内部默认的 tabs
18
+ const defaultTabs: SettingTab[] = [
19
+ {
20
+ id: "general",
21
+ title: "通用",
22
+ component: <div className="text-gray-500 dark:text-gray-400">通用设置内容将在此显示</div>,
23
+ },
24
+ {
25
+ id: "server-login",
26
+ title: "服务器",
27
+ component: <LoginSettings />,
28
+ },
29
+ // 更多设置页面将在这里添加
30
+ ];
31
+
32
+ // 合并外部 tabs 和内部默认 tabs
33
+ const tabs = externalTabs ? [...defaultTabs, ...externalTabs] : defaultTabs;
34
+
35
+ // 设置默认激活的 tab 为第一个 tab
36
+ const [activeTab, setActiveTab] = useState<string>("general");
37
+
38
+ // 当 tabs 改变时,确保 activeTab 是有效的
39
+ useEffect(() => {
40
+ if (tabs.length > 0 && !tabs.find((tab) => tab.id === activeTab)) {
41
+ setActiveTab(tabs[0].id);
42
+ }
43
+ }, [tabs, activeTab]);
44
+
45
+ if (!isOpen) return null;
46
+
47
+ return (
48
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 backdrop-blur-sm">
49
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl h-[80vh] flex flex-col relative overflow-hidden ring-1 ring-gray-900/5 dark:ring-white/10">
50
+ {/* 关闭按钮 */}
51
+ <button
52
+ className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 z-10 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
53
+ onClick={onClose}
54
+ aria-label="关闭设置"
55
+ >
56
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
57
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
58
+ </svg>
59
+ </button>
60
+
61
+ <div className="flex flex-1">
62
+ {/* 左侧 Tab List */}
63
+ <div className="w-1/4 border-r border-gray-200 dark:border-gray-700 p-4 flex flex-col">
64
+ <h2 className="text-2xl font-bold mb-6 text-gray-900 dark:text-white">设置</h2>
65
+ <nav className="flex-1">
66
+ <ul>
67
+ {tabs.map((tab) => (
68
+ <li key={tab.id}>
69
+ <button
70
+ id={tab.id + "-button"}
71
+ className={`flex items-center w-full text-left py-2 px-3 rounded-md mb-2 text-base
72
+ ${activeTab === tab.id ? "bg-blue-500 text-white font-semibold shadow-sm" : "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"}
73
+ transition-colors duration-200 ease-in-out`}
74
+ onClick={() => setActiveTab(tab.id)}
75
+ >
76
+ {tab.title}
77
+ </button>
78
+ </li>
79
+ ))}
80
+ </ul>
81
+ </nav>
82
+ </div>
83
+
84
+ {/* 右侧 Form Content */}
85
+ <div className="w-3/4 p-6 overflow-y-auto bg-gray-50 dark:bg-gray-900">
86
+ <div className="max-w-md mx-auto">
87
+ <h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">{tabs.find((tab) => tab.id === activeTab)?.title} 设置</h3>
88
+ {tabs.find((tab) => tab.id === activeTab)?.component}
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ );
95
+ };
96
+
97
+ export default SettingPanel;