feat: add n18n
This commit is contained in:
Generated
+88
@@ -19,10 +19,13 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"i18next": "^25.9.0",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-grid-layout": "^2.2.2",
|
"react-grid-layout": "^2.2.2",
|
||||||
|
"react-i18next": "^16.5.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
@@ -6169,6 +6172,15 @@
|
|||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"void-elements": "3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-url-attributes": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
@@ -6231,6 +6243,46 @@
|
|||||||
"node": ">=18.18.0"
|
"node": ">=18.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "25.9.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.9.0.tgz",
|
||||||
|
"integrity": "sha512-mJ4rVRNWOTkqh5xnaGR6iMFT5vEw3Y2MTJhcjinR/7u8cRv6dAfC0ofuePh5fVPxoh395p6JdrJTStCcNW66gg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://locize.com/i18next.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
|
"version": "8.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||||
|
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -8932,6 +8984,33 @@
|
|||||||
"react-dom": ">= 16.3.0"
|
"react-dom": ">= 16.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "16.5.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.8.tgz",
|
||||||
|
"integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"html-parse-stringify": "^3.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 25.6.2",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.4.tgz",
|
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.4.tgz",
|
||||||
@@ -11192,6 +11271,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/void-elements": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-namespaces": {
|
"node_modules/web-namespaces": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||||
|
|||||||
@@ -21,10 +21,13 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"i18next": "^25.9.0",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-grid-layout": "^2.2.2",
|
"react-grid-layout": "^2.2.2",
|
||||||
|
"react-i18next": "^16.5.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { User, Loader2, Sparkles, ArrowUp, ChevronDown, Paperclip, Check, X, Square, Plus, Database, Wand2, Search, Zap, LayoutGrid, CheckCircle2, Table, XCircle, Settings, ExternalLink } from "lucide-react";
|
import { User, Loader2, ArrowUp, ChevronDown, Check, Square, Plus, Database, Wand2, Zap, CheckCircle2, Table, XCircle, Settings, ExternalLink } from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { type ChartSpec } from "@/store/visualizationStore";
|
import { type ChartSpec } from "@/store/visualizationStore";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
@@ -12,6 +10,7 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { InlineVisualizationCard } from "./InlineVisualizationCard";
|
import { InlineVisualizationCard } from "./InlineVisualizationCard";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { SlashCommandMenu } from "./SlashCommandMenu";
|
import { SlashCommandMenu } from "./SlashCommandMenu";
|
||||||
@@ -99,6 +98,7 @@ interface SessionData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInterface() {
|
export function ChatInterface() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [messagesBySession, setMessagesBySession] = useState<Record<string, Message[]>>({});
|
const [messagesBySession, setMessagesBySession] = useState<Record<string, Message[]>>({});
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [selectedDataSource, setSelectedDataSource] = useState<string>("");
|
const [selectedDataSource, setSelectedDataSource] = useState<string>("");
|
||||||
@@ -214,7 +214,7 @@ export function ChatInterface() {
|
|||||||
// File upload state
|
// File upload state
|
||||||
const [attachedFile, setAttachedFile] = useState<DataFileContext | null>(null);
|
const [attachedFile, setAttachedFile] = useState<DataFileContext | null>(null);
|
||||||
const [activeDataFile, setActiveDataFile] = useState<DataFileContext | null>(null);
|
const [activeDataFile, setActiveDataFile] = useState<DataFileContext | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [, setIsUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -337,7 +337,7 @@ export function ChatInterface() {
|
|||||||
|
|
||||||
const currentModel = models.find(m => m.id === selectedModelId);
|
const currentModel = models.find(m => m.id === selectedModelId);
|
||||||
|
|
||||||
const chartIntentPattern = /(图表|可视化|画图|作图|柱状图|折线图|饼图|趋势|分布|chart|plot|visuali[sz]e)/i;
|
const chartIntentPattern = new RegExp(t('chartIntentPattern'), 'i');
|
||||||
|
|
||||||
const buildMessageViz = (payload: {
|
const buildMessageViz = (payload: {
|
||||||
sql?: string;
|
sql?: string;
|
||||||
@@ -419,7 +419,7 @@ export function ChatInterface() {
|
|||||||
{selectedDataSource ? (
|
{selectedDataSource ? (
|
||||||
<div className="px-3 py-1.5 rounded-full text-xs border flex items-center gap-1.5 bg-blue-50 text-blue-700 border-blue-200">
|
<div className="px-3 py-1.5 rounded-full text-xs border flex items-center gap-1.5 bg-blue-50 text-blue-700 border-blue-200">
|
||||||
<Database className="h-3.5 w-3.5" />
|
<Database className="h-3.5 w-3.5" />
|
||||||
{`数据源:${selectedDataSourceName}`}
|
{`${t('dataSource')}:${selectedDataSourceName}`}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedSkills.map((skill) => (
|
{selectedSkills.map((skill) => (
|
||||||
@@ -447,7 +447,7 @@ export function ChatInterface() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 pr-6">
|
<div className="flex-1 min-w-0 pr-6">
|
||||||
<div className="text-sm font-bold text-zinc-900 truncate">{file.filename}</div>
|
<div className="text-sm font-bold text-zinc-900 truncate">{file.filename}</div>
|
||||||
<div className="text-xs text-zinc-500">电子表格</div>
|
<div className="text-xs text-zinc-500">{t('spreadsheet')}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveFile}
|
onClick={handleRemoveFile}
|
||||||
@@ -491,7 +491,7 @@ export function ChatInterface() {
|
|||||||
setMessagesForSession(activeSessionKey, (prev) =>
|
setMessagesForSession(activeSessionKey, (prev) =>
|
||||||
prev.map((msg) =>
|
prev.map((msg) =>
|
||||||
msg.awaitingFirstToken
|
msg.awaitingFirstToken
|
||||||
? { ...msg, awaitingFirstToken: false, content: msg.content || "已中断输出" }
|
? { ...msg, awaitingFirstToken: false, content: msg.content || t('outputInterrupted') }
|
||||||
: msg
|
: msg
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -508,7 +508,7 @@ export function ChatInterface() {
|
|||||||
let messagePayload = newMessage.content;
|
let messagePayload = newMessage.content;
|
||||||
const currentAttachedFile = attachedFile;
|
const currentAttachedFile = attachedFile;
|
||||||
if (currentAttachedFile) {
|
if (currentAttachedFile) {
|
||||||
messagePayload = `[用户上传了文件: ${currentAttachedFile.filename}]\n[文件内容摘要: ${currentAttachedFile.summary || "无"}]\n[数据列: ${currentAttachedFile.columns?.join(", ") || "无"}]\n[文件下载链接: ${currentAttachedFile.url}]\n\n${newMessage.content}`;
|
messagePayload = `[${t('userUploadedFile')}: ${currentAttachedFile.filename}]\n[${t('fileContentSummary')}: ${currentAttachedFile.summary || t('none')}]\n[${t('dataColumns')}: ${currentAttachedFile.columns?.join(", ") || t('none')}]\n[${t('fileDownloadLink')}: ${currentAttachedFile.url}]\n\n${newMessage.content}`;
|
||||||
setAttachedFile(null);
|
setAttachedFile(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +524,7 @@ export function ChatInterface() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
awaitingFirstToken: true,
|
awaitingFirstToken: true,
|
||||||
progressLogs: ["请求已提交,准备路由..."],
|
progressLogs: [t('requestSubmittedRouting')],
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
const pushProgressLog = (text: string, isReasoningToken: boolean = false) => {
|
const pushProgressLog = (text: string, isReasoningToken: boolean = false) => {
|
||||||
@@ -581,7 +581,7 @@ export function ChatInterface() {
|
|||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
const err = await response.json().catch(() => ({}));
|
const err = await response.json().catch(() => ({}));
|
||||||
throw new Error(err.detail || "流式响应失败");
|
throw new Error(err.detail || t('streamResponseFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
@@ -651,9 +651,9 @@ export function ChatInterface() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === "routing") {
|
if (payload.type === "routing") {
|
||||||
const selected = payload.selected === "sql" ? "SQL 分析" : "通用对话";
|
const selected = payload.selected === "sql" ? t('sqlAnalysis') : t('generalConversation');
|
||||||
const reason = payload.reason ? `(${payload.reason})` : "";
|
const reason = payload.reason ? `(${payload.reason})` : "";
|
||||||
pushProgressLog(`路由:${selected}${reason}`);
|
pushProgressLog(t('routingInfo', { selected, reason }));
|
||||||
setMessagesForSession(targetSessionKey, (prev) =>
|
setMessagesForSession(targetSessionKey, (prev) =>
|
||||||
prev.map((msg) =>
|
prev.map((msg) =>
|
||||||
msg.id === assistantId ? { ...msg, routeInfo: `${selected}${reason}` } : msg
|
msg.id === assistantId ? { ...msg, routeInfo: `${selected}${reason}` } : msg
|
||||||
@@ -671,7 +671,7 @@ export function ChatInterface() {
|
|||||||
hasFinalPayload = true;
|
hasFinalPayload = true;
|
||||||
streamedText = payload.content;
|
streamedText = payload.content;
|
||||||
flushAssistant(true);
|
flushAssistant(true);
|
||||||
pushProgressLog("回答生成完成");
|
pushProgressLog(t('answerGenerationCompleted'));
|
||||||
setMessagesForSession(targetSessionKey, (prev) =>
|
setMessagesForSession(targetSessionKey, (prev) =>
|
||||||
prev.map((msg) =>
|
prev.map((msg) =>
|
||||||
msg.id === assistantId ? { ...msg, content: payload.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
|
msg.id === assistantId ? { ...msg, content: payload.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
|
||||||
@@ -684,14 +684,14 @@ export function ChatInterface() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === "error") {
|
if (payload.type === "error") {
|
||||||
throw new Error(payload.content || "流式响应错误");
|
throw new Error(payload.content || t('streamResponseError'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === "viz") {
|
if (payload.type === "viz") {
|
||||||
if (payload.chart?.chart_spec) {
|
if (payload.chart?.chart_spec) {
|
||||||
pushProgressLog("图表生成完成");
|
pushProgressLog(t('chartGenerationCompleted'));
|
||||||
} else if (payload.sql) {
|
} else if (payload.sql) {
|
||||||
pushProgressLog("数据查询完成");
|
pushProgressLog(t('dataQueryCompleted'));
|
||||||
}
|
}
|
||||||
streamedViz = buildMessageViz(payload);
|
streamedViz = buildMessageViz(payload);
|
||||||
flushAssistant(true); // 立即把 viz 状态刷入 messages
|
flushAssistant(true); // 立即把 viz 状态刷入 messages
|
||||||
@@ -703,7 +703,7 @@ export function ChatInterface() {
|
|||||||
if (!streamedText && (hasFinalPayload || hasDonePayload)) {
|
if (!streamedText && (hasFinalPayload || hasDonePayload)) {
|
||||||
setMessagesForSession(targetSessionKey, (prev) =>
|
setMessagesForSession(targetSessionKey, (prev) =>
|
||||||
prev.map((msg) =>
|
prev.map((msg) =>
|
||||||
msg.id === assistantId ? { ...msg, content: "暂无回复", awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
|
msg.id === assistantId ? { ...msg, content: t('noReply'), awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -712,7 +712,7 @@ export function ChatInterface() {
|
|||||||
setMessagesForSession(targetSessionKey, (prev) =>
|
setMessagesForSession(targetSessionKey, (prev) =>
|
||||||
prev.map((msg) =>
|
prev.map((msg) =>
|
||||||
msg.awaitingFirstToken
|
msg.awaitingFirstToken
|
||||||
? { ...msg, awaitingFirstToken: false, content: msg.content || "已中断输出" }
|
? { ...msg, awaitingFirstToken: false, content: msg.content || t('outputInterrupted') }
|
||||||
: msg
|
: msg
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -746,10 +746,10 @@ export function ChatInterface() {
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[280px] p-0" align="start">
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="搜索模型..." />
|
<CommandInput placeholder={t('searchModel')} />
|
||||||
<CommandList className="max-h-[300px]">
|
<CommandList className="max-h-[300px]">
|
||||||
<CommandEmpty>未找到模型</CommandEmpty>
|
<CommandEmpty>{t('modelNotFound')}</CommandEmpty>
|
||||||
<CommandGroup heading="可用模型">
|
<CommandGroup heading={t('availableModels')}>
|
||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
@@ -818,7 +818,7 @@ export function ChatInterface() {
|
|||||||
<div className="flex-1 p-3 bg-zinc-50/50">
|
<div className="flex-1 p-3 bg-zinc-50/50">
|
||||||
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
|
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
|
||||||
<Database className="h-3 w-3" />
|
<Database className="h-3 w-3" />
|
||||||
数据源
|
{t('dataSource')}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{availableDataSources.map((ds) => (
|
{availableDataSources.map((ds) => (
|
||||||
@@ -849,7 +849,7 @@ export function ChatInterface() {
|
|||||||
}}
|
}}
|
||||||
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
|
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
清除已选
|
{t('clearSelected')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -893,7 +893,7 @@ export function ChatInterface() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-8 text-center">
|
<div className="px-3 py-8 text-center">
|
||||||
<Zap className="h-8 w-8 text-zinc-100 mx-auto mb-2" />
|
<Zap className="h-8 w-8 text-zinc-100 mx-auto mb-2" />
|
||||||
<p className="text-xs text-zinc-400">暂无可用技能</p>
|
<p className="text-xs text-zinc-400">{t('noAvailableSkills')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -903,7 +903,7 @@ export function ChatInterface() {
|
|||||||
onClick={() => setSelectedSkillIds([])}
|
onClick={() => setSelectedSkillIds([])}
|
||||||
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
|
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
清除已选 ({selectedSkillIds.length})
|
{t('clearSelectedWithCount', { count: selectedSkillIds.length })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -918,7 +918,7 @@ export function ChatInterface() {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
placeholder="有问题,尽管问"
|
placeholder={t('askAnything')}
|
||||||
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
|
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -984,7 +984,7 @@ export function ChatInterface() {
|
|||||||
<div className="mb-3 rounded-xl border border-zinc-200 bg-zinc-50/50 p-3 text-sm text-zinc-600 font-mono whitespace-pre-wrap leading-relaxed shadow-inner max-h-[300px] overflow-y-auto">
|
<div className="mb-3 rounded-xl border border-zinc-200 bg-zinc-50/50 p-3 text-sm text-zinc-600 font-mono whitespace-pre-wrap leading-relaxed shadow-inner max-h-[300px] overflow-y-auto">
|
||||||
<div className="flex items-center gap-2 mb-2 text-xs font-semibold text-zinc-500 uppercase tracking-wider">
|
<div className="flex items-center gap-2 mb-2 text-xs font-semibold text-zinc-500 uppercase tracking-wider">
|
||||||
<Settings className={`h-3.5 w-3.5 ${msg.awaitingFirstToken ? 'animate-spin' : ''}`} />
|
<Settings className={`h-3.5 w-3.5 ${msg.awaitingFirstToken ? 'animate-spin' : ''}`} />
|
||||||
思考过程
|
{t('thinkingProcess')}
|
||||||
</div>
|
</div>
|
||||||
{msg.reasoningContent}
|
{msg.reasoningContent}
|
||||||
</div>
|
</div>
|
||||||
@@ -993,13 +993,13 @@ export function ChatInterface() {
|
|||||||
<div className="mb-2 rounded-xl border border-zinc-100 bg-zinc-50/70 px-3 py-2">
|
<div className="mb-2 rounded-xl border border-zinc-100 bg-zinc-50/70 px-3 py-2">
|
||||||
<div className="flex items-center gap-2 text-zinc-500 text-xs mb-1.5 pb-1.5 border-b border-zinc-100/50">
|
<div className="flex items-center gap-2 text-zinc-500 text-xs mb-1.5 pb-1.5 border-b border-zinc-100/50">
|
||||||
{msg.awaitingFirstToken ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />}
|
{msg.awaitingFirstToken ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />}
|
||||||
<span>{msg.awaitingFirstToken ? "正在处理中" : "处理完成"}</span>
|
<span>{msg.awaitingFirstToken ? t('processing') : t('processCompleted')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 max-h-[160px] overflow-y-auto pr-1">
|
<div className="space-y-1.5 max-h-[160px] overflow-y-auto pr-1">
|
||||||
{msg.progressLogs.map((log, idx, arr) => {
|
{msg.progressLogs.map((log, idx, arr) => {
|
||||||
const isLast = idx === arr.length - 1;
|
const isLast = idx === arr.length - 1;
|
||||||
// 如果是正在处理的会话,且当前日志是最后一条,或者是明确包含“正在”的日志,则显示 loading
|
// 如果是正在处理的会话,且当前日志是最后一条,或者是明确包含“正在”的日志,则显示 loading
|
||||||
const isLoadingLog = (isLast && msg.awaitingFirstToken) || log.includes("正在");
|
const isLoadingLog = (isLast && msg.awaitingFirstToken) || log.includes(t('processingIndicator'));
|
||||||
return (
|
return (
|
||||||
<div key={`${msg.id}-log-${idx}`} className="flex items-start gap-2 text-[12px] text-zinc-500 leading-5">
|
<div key={`${msg.id}-log-${idx}`} className="flex items-start gap-2 text-[12px] text-zinc-500 leading-5">
|
||||||
{isLoadingLog && msg.awaitingFirstToken ? (
|
{isLoadingLog && msg.awaitingFirstToken ? (
|
||||||
@@ -1017,7 +1017,7 @@ export function ChatInterface() {
|
|||||||
{msg.awaitingFirstToken && !msg.content ? (
|
{msg.awaitingFirstToken && !msg.content ? (
|
||||||
<div className="flex items-center gap-2 text-zinc-500 text-sm py-1">
|
<div className="flex items-center gap-2 text-zinc-500 text-sm py-1">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<span>模型思考中,请稍候...</span>
|
<span>{t('modelThinking')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1047,7 +1047,7 @@ export function ChatInterface() {
|
|||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 rounded-lg text-sm font-medium transition-colors"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 rounded-lg text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
在新标签页中打开分析报告
|
{t('openReportInNewTab')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1095,7 +1095,7 @@ export function ChatInterface() {
|
|||||||
<div className="flex-1 p-3 bg-zinc-50/50">
|
<div className="flex-1 p-3 bg-zinc-50/50">
|
||||||
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
|
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
|
||||||
<Database className="h-3 w-3" />
|
<Database className="h-3 w-3" />
|
||||||
数据源
|
{t('dataSource')}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{availableDataSources.map((ds) => (
|
{availableDataSources.map((ds) => (
|
||||||
@@ -1126,7 +1126,7 @@ export function ChatInterface() {
|
|||||||
}}
|
}}
|
||||||
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
|
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
清除已选
|
{t('clearSelected')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1170,7 +1170,7 @@ export function ChatInterface() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-8 text-center">
|
<div className="px-3 py-8 text-center">
|
||||||
<Zap className="h-8 w-8 text-zinc-100 mx-auto mb-2" />
|
<Zap className="h-8 w-8 text-zinc-100 mx-auto mb-2" />
|
||||||
<p className="text-xs text-zinc-400">暂无可用技能</p>
|
<p className="text-xs text-zinc-400">{t('noAvailableSkills')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1180,7 +1180,7 @@ export function ChatInterface() {
|
|||||||
onClick={() => setSelectedSkillIds([])}
|
onClick={() => setSelectedSkillIds([])}
|
||||||
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
|
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
清除已选 ({selectedSkillIds.length})
|
{t('clearSelectedWithCount', { count: selectedSkillIds.length })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1195,7 +1195,7 @@ export function ChatInterface() {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
placeholder="有问题,尽管问"
|
placeholder={t('askAnything')}
|
||||||
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
|
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -1229,7 +1229,7 @@ export function ChatInterface() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex justify-center">
|
<div className="mt-2 flex justify-center">
|
||||||
<p className="text-[11px] text-zinc-400">
|
<p className="text-[11px] text-zinc-400">
|
||||||
DataClaw 可能会出错。请核查重要信息。
|
{t('dataClawDisclaimer')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useRef } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Loader2, Check, AlertTriangle, Upload } from "lucide-react";
|
import { Loader2, Check, AlertTriangle, Upload } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
export interface DataSourceConfig {
|
export interface DataSourceConfig {
|
||||||
@@ -19,6 +20,7 @@ interface DataSourceFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: DataSourceFormProps) {
|
export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: DataSourceFormProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState(initialData?.name || "");
|
const [name, setName] = useState(initialData?.name || "");
|
||||||
const [type, setType] = useState(initialData?.type || "postgres");
|
const [type, setType] = useState(initialData?.type || "postgres");
|
||||||
const [config, setConfig] = useState<Record<string, any>>(initialData?.config || {});
|
const [config, setConfig] = useState<Record<string, any>>(initialData?.config || {});
|
||||||
@@ -52,7 +54,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Upload failed", error);
|
console.error("Upload failed", error);
|
||||||
alert("上传失败");
|
alert(t('uploadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
// Clear input value so same file can be selected again
|
// Clear input value so same file can be selected again
|
||||||
@@ -69,12 +71,12 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
const success = await onTest(type, config);
|
const success = await onTest(type, config);
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success,
|
success,
|
||||||
message: success ? "连接成功" : "连接失败",
|
message: success ? t('connectionSuccess') : t('connectionFailed'),
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
message: e.message || "连接失败",
|
message: e.message || t('connectionFailed'),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
@@ -141,7 +143,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-zinc-500 pt-2">
|
<div className="text-xs text-zinc-500 pt-2">
|
||||||
或者直接使用 Supabase 控制台提供的 Connection String (URI):
|
{t('orUseSupabaseConnectionString')}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Connection String</label>
|
<label className="text-sm font-medium">Connection String</label>
|
||||||
@@ -206,7 +208,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-zinc-500 pt-2">
|
<div className="text-xs text-zinc-500 pt-2">
|
||||||
或者使用连接字符串 (覆盖上述设置):
|
{t('orUseConnectionString')}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Connection String</label>
|
<label className="text-sm font-medium">Connection String</label>
|
||||||
@@ -273,7 +275,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">文件上传</label>
|
<label className="text-sm font-medium">{t('fileUpload')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={config.file_path || ""}
|
value={config.file_path || ""}
|
||||||
@@ -292,7 +294,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-zinc-500">上传文件或输入服务器路径</p>
|
<p className="text-xs text-zinc-500">{t('uploadFileOrEnterPath')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -300,9 +302,9 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<AlertTriangle className="h-10 w-10 text-amber-500 mb-3" />
|
<AlertTriangle className="h-10 w-10 text-amber-500 mb-3" />
|
||||||
<h3 className="font-medium text-zinc-900">暂不支持该数据源类型</h3>
|
<h3 className="font-medium text-zinc-900">{t('unsupportedDataSourceType')}</h3>
|
||||||
<p className="text-sm text-zinc-500 mt-1 max-w-[300px]">
|
<p className="text-sm text-zinc-500 mt-1 max-w-[300px]">
|
||||||
该数据源连接器正在开发中。请尝试使用 PostgreSQL, ClickHouse 或文件上传。
|
{t('dataSourceConnectorInDevelopment')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -312,18 +314,18 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">名称</label>
|
<label className="text-sm font-medium">{t('name')}</label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
placeholder="我的数据源"
|
placeholder={t('myDataSource')}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!initialData?.type && (
|
{!initialData?.type && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">类型</label>
|
<label className="text-sm font-medium">{t('type')}</label>
|
||||||
<select
|
<select
|
||||||
className="w-full h-10 px-3 rounded-md border border-zinc-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:border-transparent"
|
className="w-full h-10 px-3 rounded-md border border-zinc-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:border-transparent"
|
||||||
value={type}
|
value={type}
|
||||||
@@ -353,7 +355,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
取消
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -362,11 +364,11 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
disabled={isTesting}
|
disabled={isTesting}
|
||||||
>
|
>
|
||||||
{isTesting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isTesting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
测试连接
|
{t('testConnection')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving}>
|
<Button type="submit" disabled={isSaving}>
|
||||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
保存
|
{t('save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Component, type ReactNode } from "react";
|
import { Component, type ReactNode } from "react";
|
||||||
|
import { withTranslation, type WithTranslation } from "react-i18next";
|
||||||
|
|
||||||
type ErrorBoundaryProps = {
|
type ErrorBoundaryProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
} & WithTranslation;
|
||||||
|
|
||||||
type ErrorBoundaryState = {
|
type ErrorBoundaryState = {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
class ErrorBoundaryComponent extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
state: ErrorBoundaryState = {
|
state: ErrorBoundaryState = {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
message: "",
|
message: "",
|
||||||
@@ -27,11 +28,12 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { t } = this.props;
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen flex items-center justify-center bg-background text-foreground p-6">
|
<div className="h-screen w-screen flex items-center justify-center bg-background text-foreground p-6">
|
||||||
<div className="max-w-lg text-center">
|
<div className="max-w-lg text-center">
|
||||||
<h1 className="text-xl font-semibold mb-2">页面渲染失败</h1>
|
<h1 className="text-xl font-semibold mb-2">{t('pageRenderFailed')}</h1>
|
||||||
<p className="text-sm text-muted-foreground break-words">{this.state.message}</p>
|
<p className="text-sm text-muted-foreground break-words">{this.state.message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,3 +43,5 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
return this.props.children;
|
return this.props.children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ErrorBoundary = withTranslation()(ErrorBoundaryComponent);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Code, Table as TableIcon, BarChart as ChartIcon, LayoutDashboard, Copy, Check } from "lucide-react";
|
import { Code, Table as TableIcon, BarChart as ChartIcon, LayoutDashboard, Copy, Check } from "lucide-react";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
|
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ChartSpec } from "@/store/visualizationStore";
|
import type { ChartSpec } from "@/store/visualizationStore";
|
||||||
import { VegaChart } from "./VegaChart";
|
import { VegaChart } from "./VegaChart";
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
@@ -25,6 +26,7 @@ interface InlineVisualizationCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [view, setView] = useState<'table' | 'chart'>('chart');
|
const [view, setView] = useState<'table' | 'chart'>('chart');
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -87,7 +89,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full border border-zinc-100 shadow-none">
|
<Card className="w-full border border-zinc-100 shadow-none">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base">{viz.chartSpec?.title || "可视化结果"}</CardTitle>
|
<CardTitle className="text-base">{viz.chartSpec?.title || t('visualizationResult')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@@ -121,7 +123,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
|||||||
<DialogHeader className="flex flex-row items-start justify-between pr-8">
|
<DialogHeader className="flex flex-row items-start justify-between pr-8">
|
||||||
<div>
|
<div>
|
||||||
<DialogTitle>Generated SQL Query</DialogTitle>
|
<DialogTitle>Generated SQL Query</DialogTitle>
|
||||||
<DialogDescription className="mt-1">用于生成当前图表的数据查询语句。</DialogDescription>
|
<DialogDescription className="mt-1">{t('sqlQueryDescription')}</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -132,12 +134,12 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
|||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="h-3.5 w-3.5 text-emerald-500" />
|
<Check className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
<span>已复制</span>
|
<span>{t('copied')}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
<span>复制</span>
|
<span>{t('copy')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -176,7 +178,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
|||||||
<VegaChart data={objectRows} spec={viz.chartSpec} />
|
<VegaChart data={objectRows} spec={viz.chartSpec} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-zinc-500">本次结果不适合图表展示。</div>
|
<div className="text-sm text-zinc-500">{t('resultNotSuitableForChart')}</div>
|
||||||
)
|
)
|
||||||
) : objectRows.length > 0 ? (
|
) : objectRows.length > 0 ? (
|
||||||
<ScrollArea className="h-80 border rounded-md">
|
<ScrollArea className="h-80 border rounded-md">
|
||||||
@@ -198,15 +200,15 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
|||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-zinc-500">当前结果没有可渲染的结构化数据。</div>
|
<div className="text-sm text-zinc-500">{t('noStructuredDataToRender')}</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>确认加入 Dashboard</DialogTitle>
|
<DialogTitle>{t('confirmAddToDashboard')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
将当前图表添加到 Dashboard,是否继续?
|
{t('confirmAddChartToDashboardDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -217,10 +219,10 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
|||||||
setPendingChart(null);
|
setPendingChart(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
取消
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirmAdd}>
|
<Button onClick={handleConfirmAdd}>
|
||||||
确认添加
|
{t('confirmAdd')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ChevronDown, Plus, Folder } from 'lucide-react';
|
import { ChevronDown, Plus, Folder } from 'lucide-react';
|
||||||
import { useProjectStore, type Project } from '@/store/projectStore';
|
import { useProjectStore } from '@/store/projectStore';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2, Folder } from "lucide-react";
|
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2, Folder, Globe } from "lucide-react";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
@@ -45,6 +46,7 @@ function Section({
|
|||||||
onBatchDelete: (keys: string[]) => void;
|
onBatchDelete: (keys: string[]) => void;
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
|
|
||||||
@@ -96,14 +98,14 @@ function Section({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleSelectAll}
|
onClick={handleSelectAll}
|
||||||
title="全选/取消全选"
|
title={t('selectAllOrCancel')}
|
||||||
className="p-1 hover:bg-zinc-200 rounded text-zinc-500 transition-colors"
|
className="p-1 hover:bg-zinc-200 rounded text-zinc-500 transition-colors"
|
||||||
>
|
>
|
||||||
<ListChecks className="h-3.5 w-3.5" />
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleInvertSelection}
|
onClick={handleInvertSelection}
|
||||||
title="反选"
|
title={t('invertSelection')}
|
||||||
className="p-1 hover:bg-zinc-200 rounded text-zinc-500 transition-colors"
|
className="p-1 hover:bg-zinc-200 rounded text-zinc-500 transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
@@ -111,7 +113,7 @@ function Section({
|
|||||||
<button
|
<button
|
||||||
onClick={handleBatchDelete}
|
onClick={handleBatchDelete}
|
||||||
disabled={selectedKeys.length === 0}
|
disabled={selectedKeys.length === 0}
|
||||||
title="批量删除"
|
title={t('batchDelete')}
|
||||||
className={`p-1 rounded transition-colors ${
|
className={`p-1 rounded transition-colors ${
|
||||||
selectedKeys.length > 0
|
selectedKeys.length > 0
|
||||||
? "hover:bg-red-100 text-red-500"
|
? "hover:bg-red-100 text-red-500"
|
||||||
@@ -124,7 +126,7 @@ function Section({
|
|||||||
onClick={() => setIsSelectionMode(false)}
|
onClick={() => setIsSelectionMode(false)}
|
||||||
className="text-[10px] font-medium px-1.5 py-0.5 hover:bg-zinc-200 rounded text-zinc-500 transition-colors ml-1"
|
className="text-[10px] font-medium px-1.5 py-0.5 hover:bg-zinc-200 rounded text-zinc-500 transition-colors ml-1"
|
||||||
>
|
>
|
||||||
取消
|
{t('cancel')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -191,7 +193,7 @@ function Section({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
<span>重命名</span>
|
<span>{t('rename')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -206,7 +208,7 @@ function Section({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pin className="mr-2 h-4 w-4" />
|
<Pin className="mr-2 h-4 w-4" />
|
||||||
<span>{item.pinned ? "取消置顶" : "置顶"}</span>
|
<span>{item.pinned ? t('unpin') : t('pin')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -221,7 +223,7 @@ function Section({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
<span>{item.archived ? "取消归档" : "归档"}</span>
|
<span>{item.archived ? t('unarchive') : t('archive')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -237,7 +239,7 @@ function Section({
|
|||||||
className="text-red-600 focus:text-red-600 focus:bg-red-50"
|
className="text-red-600 focus:text-red-600 focus:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>删除会话</span>
|
<span>{t('deleteSession')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -254,6 +256,7 @@ function SidebarBody() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -324,7 +327,7 @@ function SidebarBody() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSession = async (key: string) => {
|
const handleDeleteSession = async (key: string) => {
|
||||||
if (!window.confirm("确定要删除这个会话吗?")) return;
|
if (!window.confirm(t('confirmDeleteSession'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/nanobot/sessions/${encodeURIComponent(key)}`);
|
await api.delete(`/nanobot/sessions/${encodeURIComponent(key)}`);
|
||||||
if (activeSessionKey === key) {
|
if (activeSessionKey === key) {
|
||||||
@@ -338,7 +341,7 @@ function SidebarBody() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchDelete = async (keys: string[]) => {
|
const handleBatchDelete = async (keys: string[]) => {
|
||||||
if (!window.confirm(`确定要删除选中的 ${keys.length} 个会话吗?`)) return;
|
if (!window.confirm(t('confirmBatchDeleteSessions', { count: keys.length }))) return;
|
||||||
try {
|
try {
|
||||||
await api.post("/nanobot/sessions/batch-delete", { session_ids: keys });
|
await api.post("/nanobot/sessions/batch-delete", { session_ids: keys });
|
||||||
if (keys.includes(activeSessionKey)) {
|
if (keys.includes(activeSessionKey)) {
|
||||||
@@ -444,7 +447,7 @@ function SidebarBody() {
|
|||||||
<Link to="/" className="flex items-center gap-1.5 text-zinc-700 font-bold text-lg hover:opacity-80 transition-opacity">
|
<Link to="/" className="flex items-center gap-1.5 text-zinc-700 font-bold text-lg hover:opacity-80 transition-opacity">
|
||||||
<span className="text-xl leading-none mr-0.5">🦞</span>
|
<span className="text-xl leading-none mr-0.5">🦞</span>
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-zinc-800 to-zinc-600">
|
<span className="bg-clip-text text-transparent bg-gradient-to-r from-zinc-800 to-zinc-600">
|
||||||
龙虾问数
|
{t('lobsterDataQA')}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="w-8" />
|
<div className="w-8" />
|
||||||
@@ -457,7 +460,7 @@ function SidebarBody() {
|
|||||||
onClick={() => navigate("/dashboard")}
|
onClick={() => navigate("/dashboard")}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-4.5 w-4.5 mr-2 text-zinc-600" />
|
<LayoutDashboard className="h-4.5 w-4.5 mr-2 text-zinc-600" />
|
||||||
Dashboard
|
{t('dashboardMenu')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -466,7 +469,7 @@ function SidebarBody() {
|
|||||||
onClick={handleNewThread}
|
onClick={handleNewThread}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
New Thread
|
{t('newThread')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -477,13 +480,13 @@ function SidebarBody() {
|
|||||||
<Input
|
<Input
|
||||||
value={sessionFilter}
|
value={sessionFilter}
|
||||||
onChange={(e) => setSessionFilter(e.target.value)}
|
onChange={(e) => setSessionFilter(e.target.value)}
|
||||||
placeholder="过滤会话名称"
|
placeholder={t('filterSessionName')}
|
||||||
className="pl-9 h-9 border-zinc-200 bg-white"
|
className="pl-9 h-9 border-zinc-200 bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Section
|
<Section
|
||||||
title="THREADS"
|
title={t('threads')}
|
||||||
count={activeSessions.length}
|
count={activeSessions.length}
|
||||||
items={activeSessions}
|
items={activeSessions}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
@@ -495,7 +498,7 @@ function SidebarBody() {
|
|||||||
activeKey={activeSessionKey}
|
activeKey={activeSessionKey}
|
||||||
/>
|
/>
|
||||||
<Section
|
<Section
|
||||||
title="ARCHIVED_THREADS"
|
title={t('archivedThreads')}
|
||||||
count={archivedSessions.length}
|
count={archivedSessions.length}
|
||||||
items={archivedSessions}
|
items={archivedSessions}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
@@ -511,13 +514,13 @@ function SidebarBody() {
|
|||||||
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>重命名会话</DialogTitle>
|
<DialogTitle>{t('renameSession')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<Input
|
<Input
|
||||||
value={newTitle}
|
value={newTitle}
|
||||||
onChange={(e) => setNewTitle(e.target.value)}
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
placeholder="输入新的会话标题"
|
placeholder={t('enterNewSessionTitle')}
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -527,8 +530,8 @@ function SidebarBody() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>取消</Button>
|
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>{t('cancel')}</Button>
|
||||||
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white" onClick={handleRename}>保存</Button>
|
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white" onClick={handleRename}>{t('save')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -543,7 +546,7 @@ function SidebarBody() {
|
|||||||
<User className="h-4.5 w-4.5" />
|
<User className="h-4.5 w-4.5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium truncate max-w-[100px] text-left">
|
<div className="text-sm font-medium truncate max-w-[100px] text-left">
|
||||||
{user?.username || 'User'}
|
{user?.username || t('defaultUser')}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -552,7 +555,7 @@ function SidebarBody() {
|
|||||||
onClick={() => navigate("/skills")}
|
onClick={() => navigate("/skills")}
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4" />
|
<Wand2 className="h-4 w-4" />
|
||||||
技能中心
|
{t('skillCenter')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -572,7 +575,7 @@ function SidebarBody() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Folder className="h-4 w-4 text-zinc-500" />
|
<Folder className="h-4 w-4 text-zinc-500" />
|
||||||
项目管理
|
{t('projectManagement')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -583,7 +586,7 @@ function SidebarBody() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Database className="h-4 w-4 text-zinc-500" />
|
<Database className="h-4 w-4 text-zinc-500" />
|
||||||
数据源管理
|
{t('dataSourceManagement')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -594,7 +597,7 @@ function SidebarBody() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4 text-zinc-500" />
|
<Settings className="h-4 w-4 text-zinc-500" />
|
||||||
个人设置
|
{t('personalSettings')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{user?.is_admin && (
|
{user?.is_admin && (
|
||||||
@@ -607,29 +610,40 @@ function SidebarBody() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Brain className="h-4 w-4 text-zinc-500" />
|
<Brain className="h-4 w-4 text-zinc-500" />
|
||||||
模型配置
|
{t('modelConfig')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50 transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate("/users");
|
navigate("/users");
|
||||||
setShowUserMenu(false);
|
setShowUserMenu(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
用户管理
|
{t('userManagement')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="h-px bg-zinc-100 my-1 mx-2" />
|
<div className="h-px bg-zinc-100 my-1 mx-2" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh');
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 text-zinc-500" />
|
||||||
|
{i18n.language === 'zh' ? 'English' : '中文'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
退出登录
|
{t('logout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Skill {
|
interface Skill {
|
||||||
@@ -17,6 +18,7 @@ interface SlashCommandMenuProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SlashCommandMenu({ isOpen, skills, selectedIndex, onSelect, onClose }: SlashCommandMenuProps) {
|
export function SlashCommandMenu({ isOpen, skills, selectedIndex, onSelect, onClose }: SlashCommandMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const selectedRef = useRef<HTMLButtonElement>(null);
|
const selectedRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ export function SlashCommandMenu({ isOpen, skills, selectedIndex, onSelect, onCl
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-bold text-blue-400 shrink-0 font-mono">/{skill.name}</span>
|
<span className="font-bold text-blue-400 shrink-0 font-mono">/{skill.name}</span>
|
||||||
<span className="text-zinc-400 truncate text-xs">{skill.description || "无描述"}</span>
|
<span className="text-zinc-400 truncate text-xs">{skill.description || t('noDescription')}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
|
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
|
||||||
import { useVisualizationStore } from "@/store/visualizationStore";
|
import { useVisualizationStore } from "@/store/visualizationStore";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { VegaChart } from "./VegaChart";
|
import { VegaChart } from "./VegaChart";
|
||||||
|
|
||||||
export function VisualizationPanel() {
|
export function VisualizationPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [view, setView] = useState<'table' | 'chart'>('chart');
|
const [view, setView] = useState<'table' | 'chart'>('chart');
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
|
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
|
||||||
@@ -206,9 +208,9 @@ export function VisualizationPanel() {
|
|||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>确认加入 Dashboard</DialogTitle>
|
<DialogTitle>{t('confirmAddToDashboard')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
将当前图表添加到 Dashboard,是否继续?
|
{t('confirmAddChartToDashboardDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -219,9 +221,9 @@ export function VisualizationPanel() {
|
|||||||
setPendingChart(null);
|
setPendingChart(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
取消
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirmAdd}>确认添加</Button>
|
<Button onClick={handleConfirmAdd}>{t('confirmAdd')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
|
import en from './locales/en.json';
|
||||||
|
import zh from './locales/zh.json';
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
en: { translation: en },
|
||||||
|
zh: { translation: zh }
|
||||||
|
},
|
||||||
|
fallbackLng: 'zh',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false // React already escapes by default
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
{
|
||||||
|
"selectAllOrCancel": "Select All / Cancel",
|
||||||
|
"invertSelection": "Invert Selection",
|
||||||
|
"batchDelete": "Batch Delete",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"rename": "Rename",
|
||||||
|
"pin": "Pin",
|
||||||
|
"unpin": "Unpin",
|
||||||
|
"archive": "Archive",
|
||||||
|
"unarchive": "Unarchive",
|
||||||
|
"deleteSession": "Delete Session",
|
||||||
|
"confirmDeleteSession": "Are you sure you want to delete this session?",
|
||||||
|
"confirmBatchDeleteSessions": "Are you sure you want to delete the selected {{count}} sessions?",
|
||||||
|
"filterSessionName": "Filter session name",
|
||||||
|
"renameSession": "Rename Session",
|
||||||
|
"enterNewSessionTitle": "Enter new session title",
|
||||||
|
"save": "Save",
|
||||||
|
"lobsterDataQA": "DataClaw",
|
||||||
|
"skillCenter": "Skill Center",
|
||||||
|
"projectManagement": "Project Management",
|
||||||
|
"dataSourceManagement": "Data Sources",
|
||||||
|
"personalSettings": "Settings",
|
||||||
|
"modelConfig": "Model Config",
|
||||||
|
"userManagement": "Users",
|
||||||
|
"logout": "Logout",
|
||||||
|
"searchModel": "Search model...",
|
||||||
|
"modelNotFound": "Model not found",
|
||||||
|
"availableModels": "Available Models",
|
||||||
|
"dataSource": "Data Source",
|
||||||
|
"clearSelected": "Clear Selected",
|
||||||
|
"clearSelectedWithCount": "Clear Selected ({{count}})",
|
||||||
|
"noAvailableSkills": "No available skills",
|
||||||
|
"askAnything": "Ask anything...",
|
||||||
|
"dataClawDisclaimer": "DataClaw can make mistakes. Consider verifying important information.",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"processCompleted": "Completed",
|
||||||
|
"thinkingProcess": "Thinking Process",
|
||||||
|
"modelThinking": "Model is thinking, please wait...",
|
||||||
|
"openReportInNewTab": "Open report in new tab",
|
||||||
|
"outputInterrupted": "Output interrupted",
|
||||||
|
"requestSubmittedRouting": "Request submitted, preparing to route...",
|
||||||
|
"routingInfo": "Routing: {{selected}} {{reason}}",
|
||||||
|
"sqlAnalysis": "SQL Analysis",
|
||||||
|
"generalConversation": "General Conversation",
|
||||||
|
"answerGenerationCompleted": "Answer generation completed",
|
||||||
|
"noReply": "No reply",
|
||||||
|
"chartGenerationCompleted": "Chart generation completed",
|
||||||
|
"dataQueryCompleted": "Data query completed",
|
||||||
|
"streamResponseFailed": "Stream response failed",
|
||||||
|
"streamResponseError": "Stream response error",
|
||||||
|
"userUploadedFile": "User uploaded file",
|
||||||
|
"fileContentSummary": "File content summary",
|
||||||
|
"none": "None",
|
||||||
|
"dataColumns": "Data columns",
|
||||||
|
"fileDownloadLink": "File download link",
|
||||||
|
"spreadsheet": "Spreadsheet",
|
||||||
|
"name": "Name",
|
||||||
|
"myDataSource": "My Data Source",
|
||||||
|
"type": "Type",
|
||||||
|
"uploadFileOrEnterPath": "Upload file or enter server path",
|
||||||
|
"unsupportedDataSourceType": "Unsupported data source type",
|
||||||
|
"dataSourceConnectorInDevelopment": "This data source connector is under development. Please try PostgreSQL, ClickHouse, or file upload.",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"uploadFailed": "Upload failed",
|
||||||
|
"connectionSuccess": "Connection successful",
|
||||||
|
"connectionFailed": "Connection failed",
|
||||||
|
"orUseSupabaseConnectionString": "Or use the Connection String (URI) provided by Supabase console:",
|
||||||
|
"orUseConnectionString": "Or use connection string (overrides above settings):",
|
||||||
|
"fileUpload": "File Upload",
|
||||||
|
"noDescription": "No description",
|
||||||
|
"confirmAddToDashboard": "Confirm Add to Dashboard",
|
||||||
|
"confirmAddChartToDashboardDesc": "Add the current chart to Dashboard, continue?",
|
||||||
|
"confirmAdd": "Confirm Add",
|
||||||
|
"visualizationResult": "Visualization Result",
|
||||||
|
"sqlQueryDescription": "The data query statement used to generate the current chart.",
|
||||||
|
"copied": "Copied",
|
||||||
|
"copy": "Copy",
|
||||||
|
"resultNotSuitableForChart": "This result is not suitable for chart display.",
|
||||||
|
"noStructuredDataToRender": "No structured data to render in the current result.",
|
||||||
|
"pageRenderFailed": "Page render failed",
|
||||||
|
"chinese": "Chinese",
|
||||||
|
"chartIntentPattern": "(图表|可视化|画图|作图|柱状图|折线图|饼图|趋势|分布|chart|plot|visuali[sz]e)",
|
||||||
|
"processingIndicator": "正在",
|
||||||
|
"confirmDeleteDataSource": "Are you sure you want to delete this data source?",
|
||||||
|
"saveFailed": "Save failed: ",
|
||||||
|
"editDataSource": "Edit Data Source",
|
||||||
|
"createNewDataSourceWithType": "Create {{type}} Data Source",
|
||||||
|
"editDataSourceWithType": "Edit {{type}} Data Source",
|
||||||
|
"backToList": "Back to List",
|
||||||
|
"dataSourceConfig": "Data Source Configuration",
|
||||||
|
"manageDataSourceConnections": "Manage data source connections for Q&A",
|
||||||
|
"newDataSource": "New Data Source",
|
||||||
|
"noDataSources": "No data sources",
|
||||||
|
"clickTopRightToAddFirstDataSource": "Click the button on the top right to add your first data source",
|
||||||
|
"newProject": "New Project",
|
||||||
|
"projectList": "Project List",
|
||||||
|
"manageProjectsDesc": "Manage your projects, different projects have independent data sources",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noProjectsCreateOne": "No projects, please create one first",
|
||||||
|
"description": "Description",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"actions": "Actions",
|
||||||
|
"manageDataSources": "Manage Data Sources",
|
||||||
|
"editProject": "Edit Project",
|
||||||
|
"deleteProject": "Delete Project",
|
||||||
|
"enterProjectName": "Enter project name",
|
||||||
|
"descriptionOptional": "Description (Optional)",
|
||||||
|
"enterProjectDescription": "Enter project description",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"create": "Create",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"confirmDeleteProject": "Are you sure you want to delete this project? All associated data sources will be deleted.",
|
||||||
|
"selectProjectToViewDashboard": "Please select a project to view the dashboard.",
|
||||||
|
"noChartsInCurrentProject": "No charts in the current project.",
|
||||||
|
"goToChatToAddCharts": "Go to the chat page and add visualization results!",
|
||||||
|
"currentTableNoData": "Current table has no data to display",
|
||||||
|
"currentTableMissingFields": "Current table data is missing fields to display",
|
||||||
|
"previewTableRows": "Preview first {{previewLimit}} rows / Total {{rowCount}} rows, {{colCount}} columns",
|
||||||
|
"totalTableRows": "Total {{rowCount}} rows, {{colCount}} columns",
|
||||||
|
"currentChartMissingFields": "Current chart data is missing fields to plot",
|
||||||
|
"passwordsDoNotMatch": "The two passwords entered do not match",
|
||||||
|
"personalSettingsSaved": "Personal settings saved successfully!",
|
||||||
|
"personalSettingsAndPasswordSaved": "Personal settings and password modified successfully!",
|
||||||
|
"failedToSaveSettings": "Failed to save settings",
|
||||||
|
"accountInfo": "Account Information",
|
||||||
|
"modifyLoginEmailAndPassword": "Modify your login email and password",
|
||||||
|
"username": "Username",
|
||||||
|
"usernameCannotBeModified": "Username cannot be modified",
|
||||||
|
"emailAddress": "Email Address",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"leaveBlankIfNotModifying": "Leave blank if not modifying",
|
||||||
|
"confirmNewPassword": "Confirm New Password",
|
||||||
|
"saveSettings": "Save Settings",
|
||||||
|
"confirmDeleteUser": "Are you sure you want to delete this user?",
|
||||||
|
"newUserMustHavePassword": "New users must have a password",
|
||||||
|
"anErrorOccurred": "An error occurred",
|
||||||
|
"addUser": "Add User",
|
||||||
|
"editUser": "Edit User",
|
||||||
|
"addNewUser": "Add New User",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"activeStatus": "Active Status",
|
||||||
|
"adminPrivileges": "Admin Privileges",
|
||||||
|
"id": "ID",
|
||||||
|
"status": "Status",
|
||||||
|
"role": "Role",
|
||||||
|
"noUserData": "No user data",
|
||||||
|
"normal": "Normal",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"admin": "Admin",
|
||||||
|
"regularUser": "Regular User",
|
||||||
|
"fillRequiredInfoFirst": "Please fill in required information first (Provider, Model ID)",
|
||||||
|
"extraConfigMustBeValidJson": "Extra config must be valid JSON",
|
||||||
|
"connectionTestSuccessful": "Connection test successful!",
|
||||||
|
"connectionTestFailed": "Connection test failed",
|
||||||
|
"fillRequiredFields": "Please fill in required fields",
|
||||||
|
"failedToSaveConfig": "Failed to save config",
|
||||||
|
"confirmDeleteModel": "Are you sure you want to delete this model?",
|
||||||
|
"noPermissionAdminOnly": "No permission to access this page, please log in with an admin account.",
|
||||||
|
"addModel": "Add Model",
|
||||||
|
"modelName": "Model Name",
|
||||||
|
"provider": "Provider",
|
||||||
|
"modelIdentifier": "Model Identifier",
|
||||||
|
"noModelData": "No model data",
|
||||||
|
"currentDefaultModel": "Current default model",
|
||||||
|
"clickToSetDefault": "Click to set as default",
|
||||||
|
"default": "Default",
|
||||||
|
"setDefault": "Set as Default",
|
||||||
|
"editModel": "Edit Model",
|
||||||
|
"providerRequired": "Provider *",
|
||||||
|
"egGpt4": "e.g., GPT-4",
|
||||||
|
"modelIdRequired": "Model ID *",
|
||||||
|
"egGpt4Turbo": "e.g., gpt-4-turbo",
|
||||||
|
"apiDomain": "API Domain",
|
||||||
|
"egApiDomain": "e.g., https://api.openai.com/v1",
|
||||||
|
"extraConfigJson": "Extra Config (JSON)",
|
||||||
|
"unknownError": "Unknown error",
|
||||||
|
"confirmDeleteSkill": "Are you sure you want to delete this skill?",
|
||||||
|
"selectProjectToManageSkills": "Please select a project at the top first to manage its skills",
|
||||||
|
"skillsRepository": "Skills Repository - {{project}}",
|
||||||
|
"manageAiSkillsDesc": "Manage AI skills and tools for this project, supports file uploads conforming to the agentskills.io standard",
|
||||||
|
"uploadSkill": "Upload Skill",
|
||||||
|
"source": "Source",
|
||||||
|
"installationTime": "Installation Time",
|
||||||
|
"noSkillsInProjectClickImport": "No skills in this project yet, click \"Upload Skill\" to start",
|
||||||
|
"viewOrEditSkill": "View/Edit Skill",
|
||||||
|
"addNewSkill": "Add New Skill",
|
||||||
|
"skillName": "Skill Name",
|
||||||
|
"selectType": "Select Type",
|
||||||
|
"selectStatus": "Select Status",
|
||||||
|
"brieflyDescribeSkillFunction": "Briefly describe the function of the skill...",
|
||||||
|
"content": "Content",
|
||||||
|
"pythonSqlApiContentPlaceholder": "Python code, SQL query template or API specification...",
|
||||||
|
"saveSkill": "Save Skill",
|
||||||
|
"safe": "Safe",
|
||||||
|
"lowRisk": "Low Risk",
|
||||||
|
"localImport": "Local Import",
|
||||||
|
"zhipuAi": "ZhipuAI",
|
||||||
|
"dashScope": "DashScope",
|
||||||
|
"volcengine": "Volcengine",
|
||||||
|
"tableRowColDesc": "TABLE · {{rowCount}} rows · {{colCount}} columns",
|
||||||
|
"projectName": "Project Name",
|
||||||
|
"dashboardMenu": "Dashboard",
|
||||||
|
"newThread": "New Thread",
|
||||||
|
"threads": "THREADS",
|
||||||
|
"archivedThreads": "ARCHIVED THREADS",
|
||||||
|
"defaultUser": "User"
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
{
|
||||||
|
"selectAllOrCancel": "全选/取消全选",
|
||||||
|
"invertSelection": "反选",
|
||||||
|
"batchDelete": "批量删除",
|
||||||
|
"cancel": "取消",
|
||||||
|
"rename": "重命名",
|
||||||
|
"pin": "置顶",
|
||||||
|
"unpin": "取消置顶",
|
||||||
|
"archive": "归档",
|
||||||
|
"unarchive": "取消归档",
|
||||||
|
"deleteSession": "删除会话",
|
||||||
|
"confirmDeleteSession": "确定要删除这个会话吗?",
|
||||||
|
"confirmBatchDeleteSessions": "确定要删除选中的 {{count}} 个会话吗?",
|
||||||
|
"filterSessionName": "过滤会话名称",
|
||||||
|
"renameSession": "重命名会话",
|
||||||
|
"enterNewSessionTitle": "输入新的会话标题",
|
||||||
|
"save": "保存",
|
||||||
|
"lobsterDataQA": "龙虾问数",
|
||||||
|
"skillCenter": "技能中心",
|
||||||
|
"projectManagement": "项目管理",
|
||||||
|
"dataSourceManagement": "数据源管理",
|
||||||
|
"personalSettings": "个人设置",
|
||||||
|
"modelConfig": "模型配置",
|
||||||
|
"userManagement": "用户管理",
|
||||||
|
"logout": "退出登录",
|
||||||
|
"searchModel": "搜索模型...",
|
||||||
|
"modelNotFound": "未找到模型",
|
||||||
|
"availableModels": "可用模型",
|
||||||
|
"dataSource": "数据源",
|
||||||
|
"clearSelected": "清除已选",
|
||||||
|
"clearSelectedWithCount": "清除已选 ({{count}})",
|
||||||
|
"noAvailableSkills": "暂无可用技能",
|
||||||
|
"askAnything": "有问题,尽管问",
|
||||||
|
"dataClawDisclaimer": "DataClaw 可能会出错。请核查重要信息。",
|
||||||
|
"processing": "正在处理中",
|
||||||
|
"processCompleted": "处理完成",
|
||||||
|
"thinkingProcess": "思考过程",
|
||||||
|
"modelThinking": "模型思考中,请稍候...",
|
||||||
|
"openReportInNewTab": "在新标签页中打开分析报告",
|
||||||
|
"outputInterrupted": "已中断输出",
|
||||||
|
"requestSubmittedRouting": "请求已提交,准备路由...",
|
||||||
|
"routingInfo": "路由:{{selected}}{{reason}}",
|
||||||
|
"sqlAnalysis": "SQL 分析",
|
||||||
|
"generalConversation": "通用对话",
|
||||||
|
"answerGenerationCompleted": "回答生成完成",
|
||||||
|
"noReply": "暂无回复",
|
||||||
|
"chartGenerationCompleted": "图表生成完成",
|
||||||
|
"dataQueryCompleted": "数据查询完成",
|
||||||
|
"streamResponseFailed": "流式响应失败",
|
||||||
|
"streamResponseError": "流式响应错误",
|
||||||
|
"userUploadedFile": "用户上传了文件",
|
||||||
|
"fileContentSummary": "文件内容摘要",
|
||||||
|
"none": "无",
|
||||||
|
"dataColumns": "数据列",
|
||||||
|
"fileDownloadLink": "文件下载链接",
|
||||||
|
"spreadsheet": "电子表格",
|
||||||
|
"name": "名称",
|
||||||
|
"myDataSource": "我的数据源",
|
||||||
|
"type": "类型",
|
||||||
|
"uploadFileOrEnterPath": "上传文件或输入服务器路径",
|
||||||
|
"unsupportedDataSourceType": "暂不支持该数据源类型",
|
||||||
|
"dataSourceConnectorInDevelopment": "该数据源连接器正在开发中。请尝试使用 PostgreSQL, ClickHouse 或文件上传。",
|
||||||
|
"testConnection": "测试连接",
|
||||||
|
"uploadFailed": "上传失败",
|
||||||
|
"connectionSuccess": "连接成功",
|
||||||
|
"connectionFailed": "连接失败",
|
||||||
|
"orUseSupabaseConnectionString": "或者直接使用 Supabase 控制台提供的 Connection String (URI):",
|
||||||
|
"orUseConnectionString": "或者使用连接字符串 (覆盖上述设置):",
|
||||||
|
"fileUpload": "文件上传",
|
||||||
|
"noDescription": "无描述",
|
||||||
|
"confirmAddToDashboard": "确认加入 Dashboard",
|
||||||
|
"confirmAddChartToDashboardDesc": "将当前图表添加到 Dashboard,是否继续?",
|
||||||
|
"confirmAdd": "确认添加",
|
||||||
|
"visualizationResult": "可视化结果",
|
||||||
|
"sqlQueryDescription": "用于生成当前图表的数据查询语句。",
|
||||||
|
"copied": "已复制",
|
||||||
|
"copy": "复制",
|
||||||
|
"resultNotSuitableForChart": "本次结果不适合图表展示。",
|
||||||
|
"noStructuredDataToRender": "当前结果没有可渲染的结构化数据。",
|
||||||
|
"pageRenderFailed": "页面渲染失败",
|
||||||
|
"chinese": "中文",
|
||||||
|
"chartIntentPattern": "(图表|可视化|画图|作图|柱状图|折线图|饼图|趋势|分布|chart|plot|visuali[sz]e)",
|
||||||
|
"processingIndicator": "正在",
|
||||||
|
"confirmDeleteDataSource": "确定要删除这个数据源吗?",
|
||||||
|
"saveFailed": "保存失败: ",
|
||||||
|
"editDataSource": "编辑数据源",
|
||||||
|
"createNewDataSourceWithType": "新建 {{type}} 数据源",
|
||||||
|
"editDataSourceWithType": "编辑 {{type}} 数据源",
|
||||||
|
"backToList": "返回列表",
|
||||||
|
"dataSourceConfig": "数据源配置",
|
||||||
|
"manageDataSourceConnections": "管理可用于问答的数据源连接",
|
||||||
|
"newDataSource": "新建数据源",
|
||||||
|
"noDataSources": "暂无数据源",
|
||||||
|
"clickTopRightToAddFirstDataSource": "点击右上角按钮添加第一个数据源",
|
||||||
|
"newProject": "新建项目",
|
||||||
|
"projectList": "项目列表",
|
||||||
|
"manageProjectsDesc": "管理您的项目,不同项目拥有独立的数据源",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noProjectsCreateOne": "暂无项目,请先创建一个",
|
||||||
|
"description": "描述",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"actions": "操作",
|
||||||
|
"manageDataSources": "管理数据源",
|
||||||
|
"editProject": "编辑项目",
|
||||||
|
"deleteProject": "删除项目",
|
||||||
|
"enterProjectName": "输入项目名称",
|
||||||
|
"descriptionOptional": "描述 (可选)",
|
||||||
|
"enterProjectDescription": "输入项目描述",
|
||||||
|
"creating": "创建中...",
|
||||||
|
"create": "创建",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"confirmDeleteProject": "确定要删除这个项目吗?所有相关的数据源都将被删除。",
|
||||||
|
"selectProjectToViewDashboard": "请选择一个项目以查看仪表板。",
|
||||||
|
"noChartsInCurrentProject": "当前项目暂无图表。",
|
||||||
|
"goToChatToAddCharts": "前往对话页并添加可视化结果!",
|
||||||
|
"currentTableNoData": "当前表格没有可展示数据",
|
||||||
|
"currentTableMissingFields": "当前表格数据缺少可展示字段",
|
||||||
|
"previewTableRows": "预览前 {{previewLimit}} 行 / 共 {{rowCount}} 行,{{colCount}} 列",
|
||||||
|
"totalTableRows": "共 {{rowCount}} 行,{{colCount}} 列",
|
||||||
|
"currentChartMissingFields": "当前图表数据缺少可绘制字段",
|
||||||
|
"passwordsDoNotMatch": "两次输入的密码不一致",
|
||||||
|
"personalSettingsSaved": "个人设置保存成功!",
|
||||||
|
"personalSettingsAndPasswordSaved": "个人设置及密码修改成功!",
|
||||||
|
"failedToSaveSettings": "保存设置失败",
|
||||||
|
"accountInfo": "账号信息",
|
||||||
|
"modifyLoginEmailAndPassword": "修改您的登录邮箱和密码",
|
||||||
|
"username": "用户名",
|
||||||
|
"usernameCannotBeModified": "用户名不可修改",
|
||||||
|
"emailAddress": "邮箱地址",
|
||||||
|
"newPassword": "新密码",
|
||||||
|
"leaveBlankIfNotModifying": "如不修改请留空",
|
||||||
|
"confirmNewPassword": "确认新密码",
|
||||||
|
"saveSettings": "保存设置",
|
||||||
|
"confirmDeleteUser": "确认删除该用户吗?",
|
||||||
|
"newUserMustHavePassword": "新建用户必须填写密码",
|
||||||
|
"anErrorOccurred": "发生错误",
|
||||||
|
"addUser": "添加用户",
|
||||||
|
"editUser": "编辑用户",
|
||||||
|
"addNewUser": "添加新用户",
|
||||||
|
"email": "邮箱",
|
||||||
|
"password": "密码",
|
||||||
|
"activeStatus": "激活状态",
|
||||||
|
"adminPrivileges": "管理员权限",
|
||||||
|
"id": "ID",
|
||||||
|
"status": "状态",
|
||||||
|
"role": "角色",
|
||||||
|
"noUserData": "暂无用户数据",
|
||||||
|
"normal": "正常",
|
||||||
|
"disabled": "禁用",
|
||||||
|
"admin": "管理员",
|
||||||
|
"regularUser": "普通用户",
|
||||||
|
"fillRequiredInfoFirst": "请先填写必要信息(供应商、模型ID)",
|
||||||
|
"extraConfigMustBeValidJson": "额外配置必须是有效的JSON",
|
||||||
|
"connectionTestSuccessful": "连接测试成功!",
|
||||||
|
"connectionTestFailed": "连接测试失败",
|
||||||
|
"fillRequiredFields": "请填写必填项",
|
||||||
|
"failedToSaveConfig": "保存配置失败",
|
||||||
|
"confirmDeleteModel": "确认删除该模型吗?",
|
||||||
|
"noPermissionAdminOnly": "无权限访问此页面,请使用管理员账号登录。",
|
||||||
|
"addModel": "添加模型",
|
||||||
|
"modelName": "模型名称",
|
||||||
|
"provider": "供应商",
|
||||||
|
"modelIdentifier": "模型标识",
|
||||||
|
"noModelData": "暂无模型数据",
|
||||||
|
"currentDefaultModel": "当前默认模型",
|
||||||
|
"clickToSetDefault": "点击设为默认",
|
||||||
|
"default": "默认",
|
||||||
|
"setDefault": "设为默认",
|
||||||
|
"editModel": "编辑模型",
|
||||||
|
"providerRequired": "供应商 *",
|
||||||
|
"egGpt4": "如:GPT-4",
|
||||||
|
"modelIdRequired": "模型ID *",
|
||||||
|
"egGpt4Turbo": "如:gpt-4-turbo",
|
||||||
|
"apiDomain": "API 域名",
|
||||||
|
"egApiDomain": "如:https://api.openai.com/v1",
|
||||||
|
"extraConfigJson": "额外配置 (JSON)",
|
||||||
|
"unknownError": "未知错误",
|
||||||
|
"confirmDeleteSkill": "确定要删除这个技能吗?",
|
||||||
|
"selectProjectToManageSkills": "请先在顶部选择一个项目以管理其技能",
|
||||||
|
"skillsRepository": "Skills 仓库 - {{project}}",
|
||||||
|
"manageAiSkillsDesc": "管理该项目的 AI 技能和工具,支持符合 agentskills.io 标准的文件上传",
|
||||||
|
"uploadSkill": "上传 Skill",
|
||||||
|
"source": "来源",
|
||||||
|
"installationTime": "安装时间",
|
||||||
|
"noSkillsInProjectClickImport": "该项目尚无技能,点击“导入 Skill”开始",
|
||||||
|
"viewOrEditSkill": "查看/编辑技能",
|
||||||
|
"addNewSkill": "添加新技能",
|
||||||
|
"skillName": "技能名称",
|
||||||
|
"selectType": "选择类型",
|
||||||
|
"selectStatus": "选择状态",
|
||||||
|
"brieflyDescribeSkillFunction": "简要描述技能的功能...",
|
||||||
|
"content": "内容",
|
||||||
|
"pythonSqlApiContentPlaceholder": "Python 代码、SQL 查询模板或 API 规范...",
|
||||||
|
"saveSkill": "保存技能",
|
||||||
|
"safe": "安全",
|
||||||
|
"lowRisk": "低风险",
|
||||||
|
"localImport": "本地导入",
|
||||||
|
"zhipuAi": "ZhipuAI (智谱)",
|
||||||
|
"dashScope": "DashScope (通义千问)",
|
||||||
|
"volcengine": "Volcengine (火山引擎)",
|
||||||
|
"tableRowColDesc": "TABLE · {{rowCount}} 行 · {{colCount}} 列",
|
||||||
|
"projectName": "项目名称",
|
||||||
|
"dashboardMenu": "仪表盘",
|
||||||
|
"newThread": "新会话",
|
||||||
|
"threads": "会话",
|
||||||
|
"archivedThreads": "已归档会话",
|
||||||
|
"defaultUser": "用户"
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
import './i18n/config'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useEffect } from 'react';
|
import { useMemo, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Responsive, WidthProvider } from 'react-grid-layout/legacy';
|
import { Responsive, WidthProvider } from 'react-grid-layout/legacy';
|
||||||
import { useDashboardStore } from '../store/dashboardStore';
|
import { useDashboardStore } from '../store/dashboardStore';
|
||||||
import { useProjectStore } from '../store/projectStore';
|
import { useProjectStore } from '../store/projectStore';
|
||||||
@@ -43,6 +44,7 @@ function inferChartKeys(data: Record<string, unknown>[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { charts, removeChart, updateLayout, loadCharts } = useDashboardStore();
|
const { charts, removeChart, updateLayout, loadCharts } = useDashboardStore();
|
||||||
const { currentProject } = useProjectStore();
|
const { currentProject } = useProjectStore();
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ export function Dashboard() {
|
|||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
<p>请选择一个项目以查看仪表板。</p>
|
<p>{t('selectProjectToViewDashboard')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,8 +89,8 @@ export function Dashboard() {
|
|||||||
if (charts.length === 0) {
|
if (charts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
<p>当前项目暂无图表。</p>
|
<p>{t('noChartsInCurrentProject')}</p>
|
||||||
<p className="text-sm">前往对话页并添加可视化结果!</p>
|
<p className="text-sm">{t('goToChatToAddCharts')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,7 +121,7 @@ export function Dashboard() {
|
|||||||
<CardTitle className="text-base">{chart.title}</CardTitle>
|
<CardTitle className="text-base">{chart.title}</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
{chart.type === "table"
|
{chart.type === "table"
|
||||||
? `TABLE · ${rows.length} 行 · ${columns.length} 列`
|
? t('tableRowColDesc', { rowCount: rows.length, colCount: columns.length })
|
||||||
: `${chart.type.toUpperCase()} Chart`}
|
: `${chart.type.toUpperCase()} Chart`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +154,7 @@ export function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex flex-col gap-2">
|
<div className="h-full w-full flex flex-col gap-2">
|
||||||
<div className="text-[11px] text-zinc-500 px-1">
|
<div className="text-[11px] text-zinc-500 px-1">
|
||||||
{isTableTruncated ? `预览前 ${TABLE_PREVIEW_LIMIT} 行 / 共 ${rows.length} 行,${columns.length} 列` : `共 ${rows.length} 行,${columns.length} 列`}
|
{isTableTruncated ? t('previewTableRows', { previewLimit: TABLE_PREVIEW_LIMIT, rowCount: rows.length, colCount: columns.length }) : t('totalTableRows', { rowCount: rows.length, colCount: columns.length })}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="flex-1 w-full border rounded-md">
|
<ScrollArea className="flex-1 w-full border rounded-md">
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { DataSourceForm, type DataSourceConfig } from "@/components/DataSourceForm";
|
import { DataSourceForm, type DataSourceConfig } from "@/components/DataSourceForm";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, Database, Pencil, Trash2, Loader2, FolderOpen, Info, ChevronLeft, FileText, Search, Network } from "lucide-react";
|
import { Plus, Database, Pencil, Trash2, Loader2, Info, ChevronLeft, FileText, Search, Network } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -30,13 +30,13 @@ const SOURCE_TYPES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function DataSources() {
|
export function DataSources() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [datasources, setDatasources] = useState<DataSourceConfig[]>([]);
|
const [datasources, setDatasources] = useState<DataSourceConfig[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [view, setView] = useState<"list" | "select-type">("list");
|
const [view, setView] = useState<"list" | "select-type">("list");
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [editingDs, setEditingDs] = useState<DataSourceConfig | null>(null);
|
const [editingDs, setEditingDs] = useState<DataSourceConfig | null>(null);
|
||||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||||
const { user } = useAuthStore();
|
|
||||||
const { currentProject } = useProjectStore();
|
const { currentProject } = useProjectStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export function DataSources() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!window.confirm("确定要删除这个数据源吗?")) return;
|
if (!window.confirm(t('confirmDeleteDataSource'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/v1/datasources/${id}`);
|
await api.delete(`/api/v1/datasources/${id}`);
|
||||||
fetchDataSources();
|
fetchDataSources();
|
||||||
@@ -98,7 +98,7 @@ export function DataSources() {
|
|||||||
fetchDataSources();
|
fetchDataSources();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save data source", e);
|
console.error("Failed to save data source", e);
|
||||||
alert("保存失败: " + (e as any).message);
|
alert(t('saveFailed') + (e as any).message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ export function DataSources() {
|
|||||||
className="flex items-center text-zinc-500 hover:text-zinc-800 transition-colors mb-6 group"
|
className="flex items-center text-zinc-500 hover:text-zinc-800 transition-colors mb-6 group"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4 mr-1 group-hover:-translate-x-0.5 transition-transform" />
|
<ChevronLeft className="h-4 w-4 mr-1 group-hover:-translate-x-0.5 transition-transform" />
|
||||||
返回列表
|
{t('backToList')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-semibold text-zinc-800 mb-6">Connect an external data source</h1>
|
<h1 className="text-2xl font-semibold text-zinc-800 mb-6">Connect an external data source</h1>
|
||||||
@@ -158,7 +158,7 @@ export function DataSources() {
|
|||||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{editingDs ? "编辑数据源" : `新建 ${SOURCE_TYPES.find(t => t.id === selectedType)?.name || ""} 数据源`}
|
{editingDs ? t('editDataSource') : t('createNewDataSourceWithType', { type: SOURCE_TYPES.find(t => t.id === selectedType)?.name || "" })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
@@ -179,12 +179,12 @@ export function DataSources() {
|
|||||||
<div className="h-full flex flex-col bg-white">
|
<div className="h-full flex flex-col bg-white">
|
||||||
<div className="border-b border-zinc-100 px-8 py-5 flex items-center justify-between">
|
<div className="border-b border-zinc-100 px-8 py-5 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-zinc-900">数据源配置</h1>
|
<h1 className="text-2xl font-bold text-zinc-900">{t('dataSourceConfig')}</h1>
|
||||||
<p className="text-sm text-zinc-500 mt-1">管理可用于问答的数据源连接</p>
|
<p className="text-sm text-zinc-500 mt-1">{t('manageDataSourceConnections')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreate} className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2">
|
<Button onClick={handleCreate} className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
新建数据源
|
{t('newDataSource')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,8 +196,8 @@ export function DataSources() {
|
|||||||
) : datasources.length === 0 ? (
|
) : datasources.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-zinc-200 rounded-xl bg-zinc-50/50">
|
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-zinc-200 rounded-xl bg-zinc-50/50">
|
||||||
<Database className="h-10 w-10 text-zinc-300 mb-3" />
|
<Database className="h-10 w-10 text-zinc-300 mb-3" />
|
||||||
<p className="text-zinc-500 font-medium">暂无数据源</p>
|
<p className="text-zinc-500 font-medium">{t('noDataSources')}</p>
|
||||||
<p className="text-zinc-400 text-sm mt-1">点击右上角按钮添加第一个数据源</p>
|
<p className="text-zinc-400 text-sm mt-1">{t('clickTopRightToAddFirstDataSource')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -256,7 +256,7 @@ export function DataSources() {
|
|||||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{editingDs ? `编辑 ${SOURCE_TYPES.find(t => t.id === editingDs.type)?.name || editingDs.type} 数据源` : `新建 ${SOURCE_TYPES.find(t => t.id === selectedType)?.name || ""} 数据源`}
|
{editingDs ? t('editDataSourceWithType', { type: SOURCE_TYPES.find(t => t.id === editingDs.type)?.name || editingDs.type }) : t('createNewDataSourceWithType', { type: SOURCE_TYPES.find(t => t.id === selectedType)?.name || "" })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -32,6 +33,7 @@ const defaultForm: Omit<ModelConfig, "id"> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ModelConfigs() {
|
export function ModelConfigs() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const isAdmin = !!user?.is_admin;
|
const isAdmin = !!user?.is_admin;
|
||||||
const [configs, setConfigs] = useState<ModelConfig[]>([]);
|
const [configs, setConfigs] = useState<ModelConfig[]>([]);
|
||||||
@@ -99,7 +101,7 @@ export function ModelConfigs() {
|
|||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
if (!form.model || !form.provider) {
|
if (!form.model || !form.provider) {
|
||||||
setError("请先填写必要信息(供应商、模型ID)");
|
setError(t('fillRequiredInfoFirst'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
@@ -111,7 +113,7 @@ export function ModelConfigs() {
|
|||||||
const parsed = JSON.parse(extraConfigText);
|
const parsed = JSON.parse(extraConfigText);
|
||||||
if (parsed && typeof parsed === "object") extraHeaders = parsed;
|
if (parsed && typeof parsed === "object") extraHeaders = parsed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("额外配置必须是有效的JSON");
|
setError(t('extraConfigMustBeValidJson'));
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -126,9 +128,9 @@ export function ModelConfigs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await api.post("/api/v1/llm/test", payload);
|
await api.post("/api/v1/llm/test", payload);
|
||||||
alert("连接测试成功!");
|
alert(t('connectionTestSuccessful'));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message || "连接测试失败");
|
setError(e.message || t('connectionTestFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
}
|
}
|
||||||
@@ -137,7 +139,7 @@ export function ModelConfigs() {
|
|||||||
const handleSave = async (e?: React.FormEvent) => {
|
const handleSave = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
if (!form.model || !form.provider) {
|
if (!form.model || !form.provider) {
|
||||||
setError("请填写必填项");
|
setError(t('fillRequiredFields'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -149,7 +151,7 @@ export function ModelConfigs() {
|
|||||||
const parsed = JSON.parse(extraConfigText);
|
const parsed = JSON.parse(extraConfigText);
|
||||||
if (parsed && typeof parsed === "object") extraHeaders = parsed;
|
if (parsed && typeof parsed === "object") extraHeaders = parsed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("额外配置必须是有效的JSON");
|
setError(t('extraConfigMustBeValidJson'));
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -168,14 +170,14 @@ export function ModelConfigs() {
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
await fetchConfigs();
|
await fetchConfigs();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message || "保存配置失败");
|
setError(e.message || t('failedToSaveConfig'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!window.confirm("确认删除该模型吗?")) return;
|
if (!window.confirm(t('confirmDeleteModel'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/v1/llm/${id}`);
|
await api.delete(`/api/v1/llm/${id}`);
|
||||||
await fetchConfigs();
|
await fetchConfigs();
|
||||||
@@ -197,7 +199,7 @@ export function ModelConfigs() {
|
|||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden items-center justify-center">
|
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden items-center justify-center">
|
||||||
<div className="text-zinc-500 text-lg">无权限访问此页面,请使用管理员账号登录。</div>
|
<div className="text-zinc-500 text-lg">{t('noPermissionAdminOnly')}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -206,21 +208,17 @@ export function ModelConfigs() {
|
|||||||
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
|
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
|
||||||
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
|
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
|
||||||
<div className="flex items-center gap-2 text-zinc-700 font-medium">
|
<div className="flex items-center gap-2 text-zinc-700 font-medium">
|
||||||
<Brain className="h-5 w-5 text-indigo-500" />
|
<Brain className="h-5 w-5 text-indigo-500" />{t('modelConfig')}</div>
|
||||||
模型配置
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="h-4 w-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
<Search className="h-4 w-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
<Input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索模型..." className="w-[200px] pl-9 h-8 text-sm" />
|
<Input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder={t('searchModel')} className="w-[200px] pl-9 h-8 text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8 text-zinc-500" onClick={fetchConfigs}>
|
<Button variant="outline" size="icon" className="h-8 w-8 text-zinc-500" onClick={fetchConfigs}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="h-8 px-3 bg-indigo-600 hover:bg-indigo-700 text-white text-sm" onClick={openCreate}>
|
<Button className="h-8 px-3 bg-indigo-600 hover:bg-indigo-700 text-white text-sm" onClick={openCreate}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />{t('addModel')}</Button>
|
||||||
添加模型
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -234,19 +232,17 @@ export function ModelConfigs() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>模型名称</TableHead>
|
<TableHead>{t('modelName')}</TableHead>
|
||||||
<TableHead>供应商</TableHead>
|
<TableHead>{t('provider')}</TableHead>
|
||||||
<TableHead>模型标识</TableHead>
|
<TableHead>{t('modelIdentifier')}</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>{t('status')}</TableHead>
|
||||||
<TableHead className="text-right">操作</TableHead>
|
<TableHead className="text-right">{t('actions')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredConfigs.length === 0 ? (
|
{filteredConfigs.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center h-24 text-zinc-500">
|
<TableCell colSpan={5} className="text-center h-24 text-zinc-500">{t('noModelData')}</TableCell>
|
||||||
暂无模型数据
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredConfigs.map((item) => (
|
filteredConfigs.map((item) => (
|
||||||
@@ -260,9 +256,9 @@ export function ModelConfigs() {
|
|||||||
<span
|
<span
|
||||||
onClick={() => handleSetDefault(item)}
|
onClick={() => handleSetDefault(item)}
|
||||||
className={`inline-flex px-2 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors ${item.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200'}`}
|
className={`inline-flex px-2 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors ${item.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200'}`}
|
||||||
title={item.is_active ? "当前默认模型" : "点击设为默认"}
|
title={item.is_active ? t('currentDefaultModel') : t('clickToSetDefault')}
|
||||||
>
|
>
|
||||||
{item.is_active ? '默认' : '设为默认'}
|
{item.is_active ? t('default') : t('setDefault')}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
@@ -296,18 +292,18 @@ export function ModelConfigs() {
|
|||||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
<form onSubmit={handleSave}>
|
<form onSubmit={handleSave}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingId ? "编辑模型" : "添加模型"}</DialogTitle>
|
<DialogTitle>{editingId ? t('editModel') : t('addModel')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-2">{error}</div>}
|
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-2">{error}</div>}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>模型名称</Label>
|
<Label>{t('modelName')}</Label>
|
||||||
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="如:GPT-4" />
|
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder={t('egGpt4')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>供应商 *</Label>
|
<Label>{t('providerRequired')}</Label>
|
||||||
<Select value={form.provider} onValueChange={(v) => setForm((p) => ({ ...p, provider: v || "openai" }))}>
|
<Select value={form.provider} onValueChange={(v) => setForm((p) => ({ ...p, provider: v || "openai" }))}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent className="max-h-[300px]">
|
<SelectContent className="max-h-[300px]">
|
||||||
@@ -318,10 +314,10 @@ export function ModelConfigs() {
|
|||||||
<SelectItem value="gemini">Google AI Studio (Gemini)</SelectItem>
|
<SelectItem value="gemini">Google AI Studio (Gemini)</SelectItem>
|
||||||
<SelectItem value="bedrock">AWS Bedrock</SelectItem>
|
<SelectItem value="bedrock">AWS Bedrock</SelectItem>
|
||||||
<SelectItem value="deepseek">DeepSeek</SelectItem>
|
<SelectItem value="deepseek">DeepSeek</SelectItem>
|
||||||
<SelectItem value="zhipuai">ZhipuAI (智谱)</SelectItem>
|
<SelectItem value="zhipuai">{t('zhipuAi')}</SelectItem>
|
||||||
<SelectItem value="moonshot">Moonshot (Kimi)</SelectItem>
|
<SelectItem value="moonshot">Moonshot (Kimi)</SelectItem>
|
||||||
<SelectItem value="dashscope">DashScope (通义千问)</SelectItem>
|
<SelectItem value="dashscope">{t('dashScope')}</SelectItem>
|
||||||
<SelectItem value="volcengine">Volcengine (火山引擎)</SelectItem>
|
<SelectItem value="volcengine">{t('volcengine')}</SelectItem>
|
||||||
<SelectItem value="groq">Groq</SelectItem>
|
<SelectItem value="groq">Groq</SelectItem>
|
||||||
<SelectItem value="cohere">Cohere</SelectItem>
|
<SelectItem value="cohere">Cohere</SelectItem>
|
||||||
<SelectItem value="mistral">Mistral</SelectItem>
|
<SelectItem value="mistral">Mistral</SelectItem>
|
||||||
@@ -336,12 +332,12 @@ export function ModelConfigs() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>模型ID *</Label>
|
<Label>{t('modelIdRequired')}</Label>
|
||||||
<Input value={form.model || ""} onChange={(e) => setForm((p) => ({ ...p, model: e.target.value }))} placeholder="如:gpt-4-turbo" required />
|
<Input value={form.model || ""} onChange={(e) => setForm((p) => ({ ...p, model: e.target.value }))} placeholder={t('egGpt4Turbo')} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>API 域名</Label>
|
<Label>{t('apiDomain')}</Label>
|
||||||
<Input value={form.api_base || ""} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="如:https://api.openai.com/v1" />
|
<Input value={form.api_base || ""} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder={t('egApiDomain')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -353,7 +349,7 @@ export function ModelConfigs() {
|
|||||||
value={form.api_key || ""}
|
value={form.api_key || ""}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))}
|
onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))}
|
||||||
className="pr-10"
|
className="pr-10"
|
||||||
placeholder="不修改请留空"
|
placeholder={t('leaveBlankIfNotModifying')}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -366,7 +362,7 @@ export function ModelConfigs() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>额外配置 (JSON)</Label>
|
<Label>{t('extraConfigJson')}</Label>
|
||||||
<Textarea value={extraConfigText} onChange={(e) => setExtraConfigText(e.target.value)} className="min-h-[80px] font-mono text-xs" placeholder='{"timeout": "60"}' />
|
<Textarea value={extraConfigText} onChange={(e) => setExtraConfigText(e.target.value)} className="min-h-[80px] font-mono text-xs" placeholder='{"timeout": "60"}' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,7 +372,7 @@ export function ModelConfigs() {
|
|||||||
测试连接
|
测试连接
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>{t('cancel')}</Button>
|
||||||
<Button type="submit" disabled={isSaving} className="bg-indigo-600 hover:bg-indigo-700 text-white">
|
<Button type="submit" disabled={isSaving} className="bg-indigo-600 hover:bg-indigo-700 text-white">
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
{isSaving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
保存
|
保存
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Plus, Folder, Pencil, Trash2, Loader2, Database } from 'lucide-react';
|
import { Plus, Folder, Pencil, Trash2, Loader2, Database } from 'lucide-react';
|
||||||
import { useProjectStore, type Project } from '@/store/projectStore';
|
import { useProjectStore, type Project } from '@/store/projectStore';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -11,6 +12,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export function Projects() {
|
export function Projects() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { projects, loading, fetchProjects, addProject, updateProject, deleteProject, setCurrentProject } = useProjectStore();
|
const { projects, loading, fetchProjects, addProject, updateProject, deleteProject, setCurrentProject } = useProjectStore();
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
@@ -53,7 +55,7 @@ export function Projects() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!window.confirm('Are you sure you want to delete this project? All associated data sources will be deleted.')) return;
|
if (!window.confirm(t('confirmDeleteProject'))) return;
|
||||||
try {
|
try {
|
||||||
await deleteProject(id);
|
await deleteProject(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -76,44 +78,40 @@ export function Projects() {
|
|||||||
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
|
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
|
||||||
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
|
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
|
||||||
<div className="flex items-center gap-2 text-zinc-700 font-medium">
|
<div className="flex items-center gap-2 text-zinc-700 font-medium">
|
||||||
<Folder className="h-5 w-5 text-blue-500" />
|
<Folder className="h-5 w-5 text-blue-500" />{t('projectManagement')}</div>
|
||||||
项目管理
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => {
|
<Button onClick={() => {
|
||||||
setFormData({ name: '', description: '' });
|
setFormData({ name: '', description: '' });
|
||||||
setIsCreateDialogOpen(true);
|
setIsCreateDialogOpen(true);
|
||||||
}} size="sm" className="gap-2">
|
}} size="sm" className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />{t('newProject')}</Button>
|
||||||
新建项目
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 p-6 overflow-auto">
|
<div className="flex-1 p-6 overflow-auto">
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
<Card className="border-zinc-200 shadow-sm">
|
<Card className="border-zinc-200 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>项目列表</CardTitle>
|
<CardTitle>{t('projectList')}</CardTitle>
|
||||||
<CardDescription>管理您的项目,不同项目拥有独立的数据源</CardDescription>
|
<CardDescription>{t('manageProjectsDesc')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading && projects.length === 0 ? (
|
{loading && projects.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-zinc-400">
|
<div className="flex flex-col items-center justify-center py-12 text-zinc-400">
|
||||||
<Loader2 className="h-8 w-8 animate-spin mb-4" />
|
<Loader2 className="h-8 w-8 animate-spin mb-4" />
|
||||||
<p>加载中...</p>
|
<p>{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<div className="text-center py-12 border-2 border-dashed rounded-lg border-zinc-100">
|
<div className="text-center py-12 border-2 border-dashed rounded-lg border-zinc-100">
|
||||||
<Folder className="h-12 w-12 text-zinc-200 mx-auto mb-4" />
|
<Folder className="h-12 w-12 text-zinc-200 mx-auto mb-4" />
|
||||||
<p className="text-zinc-500">暂无项目,请先创建一个</p>
|
<p className="text-zinc-500">{t('noProjectsCreateOne')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>名称</TableHead>
|
<TableHead>{t('name')}</TableHead>
|
||||||
<TableHead>描述</TableHead>
|
<TableHead>{t('description')}</TableHead>
|
||||||
<TableHead>创建时间</TableHead>
|
<TableHead>{t('createdAt')}</TableHead>
|
||||||
<TableHead className="text-right">操作</TableHead>
|
<TableHead className="text-right">{t('actions')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -132,7 +130,7 @@ export function Projects() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => goToDataSources(project)}
|
onClick={() => goToDataSources(project)}
|
||||||
title="管理数据源"
|
title={t('manageDataSources')}
|
||||||
>
|
>
|
||||||
<Database className="h-4 w-4 text-emerald-500" />
|
<Database className="h-4 w-4 text-emerald-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -140,7 +138,7 @@ export function Projects() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openEditDialog(project)}
|
onClick={() => openEditDialog(project)}
|
||||||
title="编辑项目"
|
title={t('editProject')}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4 text-blue-500" />
|
<Pencil className="h-4 w-4 text-blue-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -148,7 +146,7 @@ export function Projects() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(project.id)}
|
onClick={() => handleDelete(project.id)}
|
||||||
title="删除项目"
|
title={t('deleteProject')}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -168,32 +166,32 @@ export function Projects() {
|
|||||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>新建项目</DialogTitle>
|
<DialogTitle>{t('newProject')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">项目名称</Label>
|
<Label htmlFor="name">{t('projectName')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="输入项目名称"
|
placeholder={t('enterProjectName')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="description">描述 (可选)</Label>
|
<Label htmlFor="description">{t('descriptionOptional')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
placeholder="输入项目描述"
|
placeholder={t('enterProjectDescription')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>取消</Button>
|
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>{t('cancel')}</Button>
|
||||||
<Button onClick={handleCreate} disabled={isSubmitting || !formData.name.trim()}>
|
<Button onClick={handleCreate} disabled={isSubmitting || !formData.name.trim()}>
|
||||||
{isSubmitting ? '创建中...' : '创建'}
|
{isSubmitting ? t('creating') : t('create')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -203,32 +201,32 @@ export function Projects() {
|
|||||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>编辑项目</DialogTitle>
|
<DialogTitle>{t('editProject')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-name">项目名称</Label>
|
<Label htmlFor="edit-name">{t('projectName')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-name"
|
id="edit-name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="输入项目名称"
|
placeholder={t('enterProjectName')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="edit-description">描述 (可选)</Label>
|
<Label htmlFor="edit-description">{t('descriptionOptional')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="edit-description"
|
id="edit-description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
placeholder="输入项目描述"
|
placeholder={t('enterProjectDescription')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>取消</Button>
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>{t('cancel')}</Button>
|
||||||
<Button onClick={handleUpdate} disabled={isSubmitting || !formData.name.trim()}>
|
<Button onClick={handleUpdate} disabled={isSubmitting || !formData.name.trim()}>
|
||||||
{isSubmitting ? '保存中...' : '保存'}
|
{isSubmitting ? t('saving') : t('save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -8,6 +9,7 @@ import { api } from "@/lib/api";
|
|||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { user, updateUser } = useAuthStore();
|
const { user, updateUser } = useAuthStore();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -28,7 +30,7 @@ export function Settings() {
|
|||||||
setSuccess('');
|
setSuccess('');
|
||||||
|
|
||||||
if (isPasswordMismatch) {
|
if (isPasswordMismatch) {
|
||||||
setError("两次输入的密码不一致");
|
setError(t('passwordsDoNotMatch'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +46,9 @@ export function Settings() {
|
|||||||
|
|
||||||
if (user && user.id) {
|
if (user && user.id) {
|
||||||
const response = await api.put<any>(`/api/v1/users/${user.id}`, updateData);
|
const response = await api.put<any>(`/api/v1/users/${user.id}`, updateData);
|
||||||
let successMsg = "个人设置保存成功!";
|
let successMsg = t('personalSettingsSaved');
|
||||||
if (password) {
|
if (password) {
|
||||||
successMsg = "个人设置及密码修改成功!";
|
successMsg = t('personalSettingsAndPasswordSaved');
|
||||||
}
|
}
|
||||||
setSuccess(successMsg);
|
setSuccess(successMsg);
|
||||||
setPassword('');
|
setPassword('');
|
||||||
@@ -57,7 +59,7 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to save settings", error);
|
console.error("Failed to save settings", error);
|
||||||
setError(error.message || "保存设置失败");
|
setError(error.message || t('failedToSaveSettings'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -79,23 +81,23 @@ export function Settings() {
|
|||||||
|
|
||||||
<Card className="border-zinc-200 shadow-sm">
|
<Card className="border-zinc-200 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">账号信息</CardTitle>
|
<CardTitle className="text-xl">{t('accountInfo')}</CardTitle>
|
||||||
<CardDescription>修改您的登录邮箱和密码</CardDescription>
|
<CardDescription>{t('modifyLoginEmailAndPassword')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">用户名</Label>
|
<Label htmlFor="username">{t('username')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
value={user?.username || ''}
|
value={user?.username || ''}
|
||||||
disabled
|
disabled
|
||||||
className="bg-zinc-50 text-zinc-500"
|
className="bg-zinc-50 text-zinc-500"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-zinc-400">用户名不可修改</p>
|
<p className="text-xs text-zinc-400">{t('usernameCannotBeModified')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">邮箱地址</Label>
|
<Label htmlFor="email">{t('emailAddress')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -105,11 +107,11 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 pt-4 border-t border-zinc-100">
|
<div className="space-y-2 pt-4 border-t border-zinc-100">
|
||||||
<Label htmlFor="new-password">新密码</Label>
|
<Label htmlFor="new-password">{t('newPassword')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="new-password"
|
id="new-password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="如不修改请留空"
|
placeholder={t('leaveBlankIfNotModifying')}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPassword(e.target.value);
|
setPassword(e.target.value);
|
||||||
@@ -119,18 +121,18 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm-password">确认新密码</Label>
|
<Label htmlFor="confirm-password">{t('confirmNewPassword')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="如不修改请留空"
|
placeholder={t('leaveBlankIfNotModifying')}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setConfirmPassword(e.target.value);
|
setConfirmPassword(e.target.value);
|
||||||
setError('');
|
setError('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isPasswordMismatch && <p className="text-sm text-red-600">两次输入的密码不一致</p>}
|
{isPasswordMismatch && <p className="text-sm text-red-600">{t('passwordsDoNotMatch')}</p>}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="bg-zinc-50/50 border-t border-zinc-100 pt-6">
|
<CardFooter className="bg-zinc-50/50 border-t border-zinc-100 pt-6">
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Trash2, Terminal, Loader2, FolderOpen, Eye, ShieldCheck, AlertCircle, Wand2, Upload } from "lucide-react";
|
||||||
import { Trash2, Edit2, Plus, Terminal, Loader2, FolderOpen, Share2, Download, Eye, ShieldCheck, AlertCircle, Wand2, Upload } from "lucide-react";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -28,11 +27,12 @@ interface Skill {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Skills() {
|
export function Skills() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [skills, setSkills] = useState<Skill[]>([]);
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
||||||
const [newSkill, setNewSkill] = useState<Partial<Skill>>({ type: 'python', content: '', source: '本地导入', status: '安全' });
|
const [newSkill, setNewSkill] = useState<Partial<Skill>>({ type: 'python', content: '', source: t('localImport'), status: t('safe') });
|
||||||
const { currentProject } = useProjectStore();
|
const { currentProject } = useProjectStore();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ export function Skills() {
|
|||||||
await fetchSkills();
|
await fetchSkills();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to upload skill", error);
|
console.error("Failed to upload skill", error);
|
||||||
const errorMessage = error.response?.data?.detail || error.message || "未知错误";
|
const errorMessage = error.response?.data?.detail || error.message || t('unknownError');
|
||||||
alert("上传失败: " + errorMessage);
|
alert(t('uploadFailed') + ': ' + errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
@@ -95,7 +95,7 @@ export function Skills() {
|
|||||||
await api.post<Skill>('/api/v1/skills', skillToCreate);
|
await api.post<Skill>('/api/v1/skills', skillToCreate);
|
||||||
}
|
}
|
||||||
await fetchSkills();
|
await fetchSkills();
|
||||||
setNewSkill({ type: 'python', content: '', source: '本地导入', status: '安全' });
|
setNewSkill({ type: 'python', content: '', source: t('localImport'), status: t('safe') });
|
||||||
setEditingSkill(null);
|
setEditingSkill(null);
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -112,7 +112,7 @@ export function Skills() {
|
|||||||
|
|
||||||
const handleDeleteSkill = async (id: string) => {
|
const handleDeleteSkill = async (id: string) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
if (!window.confirm("确定要删除这个技能吗?")) return;
|
if (!window.confirm(t('confirmDeleteSkill'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/v1/skills/${id}?project_id=${currentProject.id}`);
|
await api.delete(`/api/v1/skills/${id}?project_id=${currentProject.id}`);
|
||||||
setSkills(skills.filter(s => s.id !== id));
|
setSkills(skills.filter(s => s.id !== id));
|
||||||
@@ -125,7 +125,7 @@ export function Skills() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-zinc-500 gap-4">
|
<div className="h-full flex flex-col items-center justify-center text-zinc-500 gap-4">
|
||||||
<FolderOpen className="h-12 w-12 text-zinc-200" />
|
<FolderOpen className="h-12 w-12 text-zinc-200" />
|
||||||
<p>请先在顶部选择一个项目以管理其技能</p>
|
<p>{t('selectProjectToManageSkills')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,10 +135,8 @@ export function Skills() {
|
|||||||
<div className="border-b border-zinc-100 px-8 py-5 flex items-center justify-between bg-white shrink-0">
|
<div className="border-b border-zinc-100 px-8 py-5 flex items-center justify-between bg-white shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-zinc-900 flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-zinc-900 flex items-center gap-2">
|
||||||
< Wand2 className="h-6 w-6 text-indigo-500" />
|
< Wand2 className="h-6 w-6 text-indigo-500" />{t('skillsRepository', { project: currentProject.name })}</h1>
|
||||||
Skills 仓库 - {currentProject.name}
|
<p className="text-sm text-zinc-500 mt-1">{t('manageAiSkillsDesc')}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-zinc-500 mt-1">管理该项目的 AI 技能和工具,支持符合 agentskills.io 标准的文件上传</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -152,9 +150,7 @@ export function Skills() {
|
|||||||
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2"
|
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />{t('uploadSkill')}</Button>
|
||||||
上传 Skill
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,11 +159,11 @@ export function Skills() {
|
|||||||
<Table className="table-fixed w-full">
|
<Table className="table-fixed w-full">
|
||||||
<TableHeader className="bg-zinc-50/50">
|
<TableHeader className="bg-zinc-50/50">
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="w-[40%] font-semibold text-zinc-700 py-3 px-4 text-sm">名称</TableHead>
|
<TableHead className="w-[40%] font-semibold text-zinc-700 py-3 px-4 text-sm">{t('name')}</TableHead>
|
||||||
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm">来源</TableHead>
|
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm">{t('source')}</TableHead>
|
||||||
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-center">安装时间</TableHead>
|
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-center">{t('installationTime')}</TableHead>
|
||||||
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-center">状态</TableHead>
|
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-center">{t('status')}</TableHead>
|
||||||
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-right">操作</TableHead>
|
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-right">{t('actions')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -214,11 +210,11 @@ export function Skills() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-4 px-4 text-center">
|
<TableCell className="py-4 px-4 text-center">
|
||||||
<div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] md:text-xs font-medium whitespace-nowrap ${
|
<div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] md:text-xs font-medium whitespace-nowrap ${
|
||||||
skill.status === '安全'
|
skill.status === t('safe')
|
||||||
? 'bg-green-50 text-green-700 border border-green-100'
|
? 'bg-green-50 text-green-700 border border-green-100'
|
||||||
: 'bg-amber-50 text-amber-700 border border-amber-100'
|
: 'bg-amber-50 text-amber-700 border border-amber-100'
|
||||||
}`}>
|
}`}>
|
||||||
{skill.status === '安全' ? (
|
{skill.status === t('safe') ? (
|
||||||
<ShieldCheck className="h-3 w-3" />
|
<ShieldCheck className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<AlertCircle className="h-3 w-3" />
|
<AlertCircle className="h-3 w-3" />
|
||||||
@@ -259,7 +255,7 @@ export function Skills() {
|
|||||||
<div className="p-4 bg-zinc-50 rounded-2xl">
|
<div className="p-4 bg-zinc-50 rounded-2xl">
|
||||||
<Terminal className="h-10 w-10 opacity-20" />
|
<Terminal className="h-10 w-10 opacity-20" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">该项目尚无技能,点击“导入 Skill”开始</p>
|
<p className="text-sm">{t('noSkillsInProjectClickImport')}</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -275,20 +271,20 @@ export function Skills() {
|
|||||||
setIsDialogOpen(open);
|
setIsDialogOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setEditingSkill(null);
|
setEditingSkill(null);
|
||||||
setNewSkill({ type: 'python', content: '', source: '本地导入', status: '安全' });
|
setNewSkill({ type: 'python', content: '', source: t('localImport'), status: t('safe') });
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col rounded-2xl p-0 overflow-hidden">
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col rounded-2xl p-0 overflow-hidden">
|
||||||
<DialogHeader className="p-6 pb-2">
|
<DialogHeader className="p-6 pb-2">
|
||||||
<DialogTitle className="text-xl font-bold text-zinc-900">{editingSkill ? '查看/编辑技能' : '添加新技能'}</DialogTitle>
|
<DialogTitle className="text-xl font-bold text-zinc-900">{editingSkill ? t('viewOrEditSkill') : t('addNewSkill')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-2">
|
<div className="flex-1 overflow-y-auto px-6 py-2">
|
||||||
<div className="grid gap-5">
|
<div className="grid gap-5">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="name" className="text-zinc-600 font-medium text-sm">名称</Label>
|
<Label htmlFor="name" className="text-zinc-600 font-medium text-sm">{t('name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
placeholder="技能名称"
|
placeholder={t('skillName')}
|
||||||
value={newSkill.name || ''}
|
value={newSkill.name || ''}
|
||||||
onChange={(e) => setNewSkill({...newSkill, name: e.target.value})}
|
onChange={(e) => setNewSkill({...newSkill, name: e.target.value})}
|
||||||
className="rounded-lg border-zinc-200 h-10"
|
className="rounded-lg border-zinc-200 h-10"
|
||||||
@@ -297,14 +293,14 @@ export function Skills() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="type" className="text-zinc-600 font-medium text-sm">类型</Label>
|
<Label htmlFor="type" className="text-zinc-600 font-medium text-sm">{t('type')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={newSkill.type}
|
value={newSkill.type}
|
||||||
onValueChange={(val: any) => setNewSkill({...newSkill, type: val})}
|
onValueChange={(val: any) => setNewSkill({...newSkill, type: val})}
|
||||||
disabled={editingSkill?.is_builtin}
|
disabled={editingSkill?.is_builtin}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
|
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
|
||||||
<SelectValue placeholder="选择类型" />
|
<SelectValue placeholder={t('selectType')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-lg">
|
<SelectContent className="rounded-lg">
|
||||||
<SelectItem value="python">Python</SelectItem>
|
<SelectItem value="python">Python</SelectItem>
|
||||||
@@ -314,27 +310,27 @@ export function Skills() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="status" className="text-zinc-600 font-medium text-sm">状态</Label>
|
<Label htmlFor="status" className="text-zinc-600 font-medium text-sm">{t('status')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={newSkill.status}
|
value={newSkill.status}
|
||||||
onValueChange={(val: any) => setNewSkill({...newSkill, status: val})}
|
onValueChange={(val: any) => setNewSkill({...newSkill, status: val})}
|
||||||
disabled={editingSkill?.is_builtin}
|
disabled={editingSkill?.is_builtin}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
|
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
|
||||||
<SelectValue placeholder="选择状态" />
|
<SelectValue placeholder={t('selectStatus')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-lg">
|
<SelectContent className="rounded-lg">
|
||||||
<SelectItem value="安全">安全</SelectItem>
|
<SelectItem value={t('safe')}>{t('safe')}</SelectItem>
|
||||||
<SelectItem value="低风险">低风险</SelectItem>
|
<SelectItem value={t('lowRisk')}>{t('lowRisk')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="description" className="text-zinc-600 font-medium text-sm">描述</Label>
|
<Label htmlFor="description" className="text-zinc-600 font-medium text-sm">{t('description')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
placeholder="简要描述技能的功能..."
|
placeholder={t('brieflyDescribeSkillFunction')}
|
||||||
value={newSkill.description || ''}
|
value={newSkill.description || ''}
|
||||||
onChange={(e) => setNewSkill({...newSkill, description: e.target.value})}
|
onChange={(e) => setNewSkill({...newSkill, description: e.target.value})}
|
||||||
className="rounded-lg border-zinc-200 min-h-[80px] py-2 text-sm"
|
className="rounded-lg border-zinc-200 min-h-[80px] py-2 text-sm"
|
||||||
@@ -342,13 +338,13 @@ export function Skills() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="content" className="text-zinc-600 font-medium text-sm">内容</Label>
|
<Label htmlFor="content" className="text-zinc-600 font-medium text-sm">{t('content')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="content"
|
id="content"
|
||||||
value={newSkill.content || ''}
|
value={newSkill.content || ''}
|
||||||
onChange={(e) => setNewSkill({...newSkill, content: e.target.value})}
|
onChange={(e) => setNewSkill({...newSkill, content: e.target.value})}
|
||||||
className="rounded-lg border-zinc-200 font-mono text-xs min-h-[160px] py-3 bg-zinc-50"
|
className="rounded-lg border-zinc-200 font-mono text-xs min-h-[160px] py-3 bg-zinc-50"
|
||||||
placeholder="Python 代码、SQL 查询模板或 API 规范..."
|
placeholder={t('pythonSqlApiContentPlaceholder')}
|
||||||
disabled={editingSkill?.is_builtin}
|
disabled={editingSkill?.is_builtin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,9 +352,7 @@ export function Skills() {
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter className="p-6 pt-2">
|
<DialogFooter className="p-6 pt-2">
|
||||||
{!editingSkill?.is_builtin && (
|
{!editingSkill?.is_builtin && (
|
||||||
<Button onClick={handleAddSkill} className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-6 h-10 w-full">
|
<Button onClick={handleAddSkill} className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-6 h-10 w-full">{t('saveSkill')}</Button>
|
||||||
保存技能
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -18,6 +19,7 @@ interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Users() {
|
export function Users() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
@@ -87,7 +89,7 @@ export function Users() {
|
|||||||
} else {
|
} else {
|
||||||
// Create
|
// Create
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
setError("新建用户必须填写密码");
|
setError(t('newUserMustHavePassword'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await api.post("/api/v1/users", formData);
|
await api.post("/api/v1/users", formData);
|
||||||
@@ -95,12 +97,12 @@ export function Users() {
|
|||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "发生错误");
|
setError(err.message || t('anErrorOccurred'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (window.confirm("确认删除该用户吗?")) {
|
if (window.confirm(t('confirmDeleteUser'))) {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/v1/users/${id}`);
|
await api.delete(`/api/v1/users/${id}`);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
@@ -125,12 +127,12 @@ export function Users() {
|
|||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingUser ? "编辑用户" : "添加新用户"}</DialogTitle>
|
<DialogTitle>{editingUser ? t('editUser') : t('addNewUser')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
{error && <div className="text-red-500 text-sm">{error}</div>}
|
{error && <div className="text-red-500 text-sm">{error}</div>}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="username">用户名</Label>
|
<Label htmlFor="username">{t('username')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
@@ -139,7 +141,7 @@ export function Users() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">邮箱</Label>
|
<Label htmlFor="email">{t('email')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -150,7 +152,7 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
{!editingUser && (
|
{!editingUser && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="password">密码</Label>
|
<Label htmlFor="password">{t('password')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -161,7 +163,7 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="is_active">激活状态</Label>
|
<Label htmlFor="is_active">{t('activeStatus')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="is_active"
|
id="is_active"
|
||||||
checked={formData.is_active}
|
checked={formData.is_active}
|
||||||
@@ -169,7 +171,7 @@ export function Users() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="is_admin">管理员权限</Label>
|
<Label htmlFor="is_admin">{t('adminPrivileges')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="is_admin"
|
id="is_admin"
|
||||||
checked={formData.is_admin}
|
checked={formData.is_admin}
|
||||||
@@ -179,10 +181,10 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
|
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||||
取消
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white">
|
<Button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white">
|
||||||
保存
|
{t('save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -200,13 +202,13 @@ export function Users() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>{t('id')}</TableHead>
|
||||||
<TableHead>用户名</TableHead>
|
<TableHead>{t('username')}</TableHead>
|
||||||
<TableHead>邮箱</TableHead>
|
<TableHead>{t('email')}</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>{t('status')}</TableHead>
|
||||||
<TableHead>角色</TableHead>
|
<TableHead>{t('role')}</TableHead>
|
||||||
<TableHead>创建时间</TableHead>
|
<TableHead>{t('createdAt')}</TableHead>
|
||||||
<TableHead className="text-right">操作</TableHead>
|
<TableHead className="text-right">{t('actions')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -224,12 +226,12 @@ export function Users() {
|
|||||||
<TableCell>{user.email}</TableCell>
|
<TableCell>{user.email}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||||
{user.is_active ? '正常' : '禁用'}
|
{user.is_active ? t('normal') : t('disabled')}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_admin ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'}`}>
|
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_admin ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||||
{user.is_admin ? '管理员' : '普通用户'}
|
{user.is_admin ? t('admin') : t('regularUser')}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-zinc-500">
|
<TableCell className="text-zinc-500">
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function saveChartsToStorage(charts: ChartConfig[], projectId: number) {
|
|||||||
window.localStorage.setItem(getStorageKey(projectId), JSON.stringify(charts));
|
window.localStorage.setItem(getStorageKey(projectId), JSON.stringify(charts));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDashboardStore = create<DashboardState>((set, get) => ({
|
export const useDashboardStore = create<DashboardState>((set) => ({
|
||||||
charts: [],
|
charts: [],
|
||||||
loadCharts: (projectId) => {
|
loadCharts: (projectId) => {
|
||||||
set({ charts: loadChartsFromStorage(projectId) });
|
set({ charts: loadChartsFromStorage(projectId) });
|
||||||
|
|||||||
Reference in New Issue
Block a user