@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.
- package/dist/assets/index-DTkiHaQ2.js +275 -0
- package/dist/assets/index-nM8bt1K3.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/chat/Chat.tsx +135 -54
- package/src/chat/components/ErrorBoundary.tsx +20 -5
- package/src/chat/components/FileList.tsx +53 -21
- package/src/chat/components/HistoryList.tsx +32 -36
- package/src/chat/components/JsonEditorPopup.tsx +19 -22
- package/src/chat/components/MessageAI.tsx +5 -7
- package/src/chat/components/MessageBox.tsx +24 -4
- package/src/chat/components/MessageHuman.tsx +108 -33
- package/src/chat/components/MessageTool.tsx +11 -11
- package/src/chat/components/UsageMetadata.tsx +53 -22
- package/src/chat/context/ChatContext.tsx +18 -4
- package/src/chat/index.css +19 -0
- package/src/chat/store/index.ts +2 -1
- package/src/index.ts +1 -0
- package/src/settings/LoginSettings.tsx +234 -0
- package/src/settings/SettingFormBase.tsx +42 -0
- package/src/settings/SettingPanel.tsx +97 -0
- package/src/sonner/Toaster.tsx +117 -0
- package/src/sonner/index.ts +3 -0
- package/src/sonner/toast.ts +50 -0
- package/src/sonner/types.ts +21 -0
- package/test/App.tsx +2 -4
- package/tsconfig.json +1 -1
- package/vite.config.ts +5 -5
- package/dist/assets/index-DzEw-fFg.css +0 -1
- package/dist/assets/index-g0-NNf-r.js +0 -268
- package/src/login/Login.tsx +0 -200
|
@@ -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 = ["
|
|
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
|
|
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
|
|
40
|
-
<div className="flex items-center justify-between
|
|
41
|
-
<div className="text-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
25
|
-
{speed && <span className="text-gray-
|
|
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-
|
|
29
|
-
{tool_call_id && <span className="text-gray-
|
|
30
|
-
{id && <span className="text-gray-
|
|
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
|
|
43
|
-
<div className="
|
|
44
|
-
<div className="
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<div
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
{formatTokens(usage_metadata.
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
package/src/chat/index.css
CHANGED
|
@@ -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
|
+
}
|
package/src/chat/store/index.ts
CHANGED
|
@@ -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("
|
|
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
|
@@ -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;
|