feat: add n18n

This commit is contained in:
qixinbo
2026-03-21 21:26:57 +08:00
parent 40f84fc98e
commit 5ab9884bf6
22 changed files with 823 additions and 273 deletions
+88
View File
@@ -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",
+3
View File
@@ -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",
+38 -38
View File
@@ -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>
+17 -15
View File
@@ -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>
+7 -3
View File
@@ -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>
+2 -2
View File
@@ -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,
+44 -30
View File
@@ -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>
)} )}
+4 -2
View File
@@ -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>
+22
View File
@@ -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;
+208
View File
@@ -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"
}
+208
View File
@@ -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": "用户"
}
+1
View File
@@ -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>
+7 -5
View File
@@ -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>
+13 -13
View File
@@ -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">
+36 -40
View File
@@ -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}
+31 -33
View File
@@ -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>
+16 -14
View File
@@ -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">
+36 -42
View File
@@ -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>
+22 -20
View File
@@ -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">
+1 -1
View File
@@ -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) });