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
+7 -5
View File
@@ -1,4 +1,5 @@
import { useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Responsive, WidthProvider } from 'react-grid-layout/legacy';
import { useDashboardStore } from '../store/dashboardStore';
import { useProjectStore } from '../store/projectStore';
@@ -43,6 +44,7 @@ function inferChartKeys(data: Record<string, unknown>[]) {
}
export function Dashboard() {
const { t } = useTranslation();
const { charts, removeChart, updateLayout, loadCharts } = useDashboardStore();
const { currentProject } = useProjectStore();
@@ -79,7 +81,7 @@ export function Dashboard() {
if (!currentProject) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<p></p>
<p>{t('selectProjectToViewDashboard')}</p>
</div>
);
}
@@ -87,8 +89,8 @@ export function Dashboard() {
if (charts.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<p></p>
<p className="text-sm"></p>
<p>{t('noChartsInCurrentProject')}</p>
<p className="text-sm">{t('goToChatToAddCharts')}</p>
</div>
);
}
@@ -119,7 +121,7 @@ export function Dashboard() {
<CardTitle className="text-base">{chart.title}</CardTitle>
<CardDescription className="text-xs">
{chart.type === "table"
? `TABLE · ${rows.length} 行 · ${columns.length}`
? t('tableRowColDesc', { rowCount: rows.length, colCount: columns.length })
: `${chart.type.toUpperCase()} Chart`}
</CardDescription>
</div>
@@ -152,7 +154,7 @@ export function Dashboard() {
return (
<div className="h-full w-full flex flex-col gap-2">
<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>
<ScrollArea className="flex-1 w-full border rounded-md">
<Table>
+13 -13
View File
@@ -1,10 +1,10 @@
import { useState, useEffect } from "react";
import { useTranslation } from 'react-i18next';
import { api } from "@/lib/api";
import { DataSourceForm, type DataSourceConfig } from "@/components/DataSourceForm";
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 { useAuthStore } from "@/store/authStore";
import { useProjectStore } from "@/store/projectStore";
import { useNavigate } from "react-router-dom";
@@ -30,13 +30,13 @@ const SOURCE_TYPES = [
];
export function DataSources() {
const { t } = useTranslation();
const [datasources, setDatasources] = useState<DataSourceConfig[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [view, setView] = useState<"list" | "select-type">("list");
const [isOpen, setIsOpen] = useState(false);
const [editingDs, setEditingDs] = useState<DataSourceConfig | null>(null);
const [selectedType, setSelectedType] = useState<string | null>(null);
const { user } = useAuthStore();
const { currentProject } = useProjectStore();
const navigate = useNavigate();
@@ -77,7 +77,7 @@ export function DataSources() {
};
const handleDelete = async (id: number) => {
if (!window.confirm("确定要删除这个数据源吗?")) return;
if (!window.confirm(t('confirmDeleteDataSource'))) return;
try {
await api.delete(`/api/v1/datasources/${id}`);
fetchDataSources();
@@ -98,7 +98,7 @@ export function DataSources() {
fetchDataSources();
} catch (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"
>
<ChevronLeft className="h-4 w-4 mr-1 group-hover:-translate-x-0.5 transition-transform" />
{t('backToList')}
</button>
<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">
<DialogHeader>
<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>
</DialogHeader>
<div className="py-4">
@@ -179,12 +179,12 @@ export function DataSources() {
<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>
<h1 className="text-2xl font-bold text-zinc-900"></h1>
<p className="text-sm text-zinc-500 mt-1"></p>
<h1 className="text-2xl font-bold text-zinc-900">{t('dataSourceConfig')}</h1>
<p className="text-sm text-zinc-500 mt-1">{t('manageDataSourceConnections')}</p>
</div>
<Button onClick={handleCreate} className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2">
<Plus className="h-4 w-4" />
{t('newDataSource')}
</Button>
</div>
@@ -196,8 +196,8 @@ export function DataSources() {
) : 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">
<Database className="h-10 w-10 text-zinc-300 mb-3" />
<p className="text-zinc-500 font-medium"></p>
<p className="text-zinc-400 text-sm mt-1"></p>
<p className="text-zinc-500 font-medium">{t('noDataSources')}</p>
<p className="text-zinc-400 text-sm mt-1">{t('clickTopRightToAddFirstDataSource')}</p>
</div>
) : (
<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">
<DialogHeader>
<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>
</DialogHeader>
<div className="py-4">
+36 -40
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -32,6 +33,7 @@ const defaultForm: Omit<ModelConfig, "id"> = {
};
export function ModelConfigs() {
const { t } = useTranslation();
const { user } = useAuthStore();
const isAdmin = !!user?.is_admin;
const [configs, setConfigs] = useState<ModelConfig[]>([]);
@@ -99,7 +101,7 @@ export function ModelConfigs() {
const handleTestConnection = async () => {
if (!form.model || !form.provider) {
setError("请先填写必要信息(供应商、模型ID)");
setError(t('fillRequiredInfoFirst'));
return;
}
setIsTesting(true);
@@ -111,7 +113,7 @@ export function ModelConfigs() {
const parsed = JSON.parse(extraConfigText);
if (parsed && typeof parsed === "object") extraHeaders = parsed;
} catch (err) {
setError("额外配置必须是有效的JSON");
setError(t('extraConfigMustBeValidJson'));
setIsTesting(false);
return;
}
@@ -126,9 +128,9 @@ export function ModelConfigs() {
};
await api.post("/api/v1/llm/test", payload);
alert("连接测试成功!");
alert(t('connectionTestSuccessful'));
} catch (e: any) {
setError(e.message || "连接测试失败");
setError(e.message || t('connectionTestFailed'));
} finally {
setIsTesting(false);
}
@@ -137,7 +139,7 @@ export function ModelConfigs() {
const handleSave = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!form.model || !form.provider) {
setError("请填写必填项");
setError(t('fillRequiredFields'));
return;
}
setIsSaving(true);
@@ -149,7 +151,7 @@ export function ModelConfigs() {
const parsed = JSON.parse(extraConfigText);
if (parsed && typeof parsed === "object") extraHeaders = parsed;
} catch (err) {
setError("额外配置必须是有效的JSON");
setError(t('extraConfigMustBeValidJson'));
setIsSaving(false);
return;
}
@@ -168,14 +170,14 @@ export function ModelConfigs() {
setDialogOpen(false);
await fetchConfigs();
} catch (e: any) {
setError(e.message || "保存配置失败");
setError(e.message || t('failedToSaveConfig'));
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm("确认删除该模型吗?")) return;
if (!window.confirm(t('confirmDeleteModel'))) return;
try {
await api.delete(`/api/v1/llm/${id}`);
await fetchConfigs();
@@ -197,7 +199,7 @@ export function ModelConfigs() {
if (!isAdmin) {
return (
<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>
);
}
@@ -206,21 +208,17 @@ export function ModelConfigs() {
<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="flex items-center gap-2 text-zinc-700 font-medium">
<Brain className="h-5 w-5 text-indigo-500" />
</div>
<Brain className="h-5 w-5 text-indigo-500" />{t('modelConfig')}</div>
<div className="flex items-center gap-3">
<div className="relative">
<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>
<Button variant="outline" size="icon" className="h-8 w-8 text-zinc-500" onClick={fetchConfigs}>
<RefreshCw className="h-4 w-4" />
</Button>
<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" />
</Button>
<Plus className="h-4 w-4 mr-1" />{t('addModel')}</Button>
</div>
</div>
@@ -234,19 +232,17 @@ export function ModelConfigs() {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t('modelName')}</TableHead>
<TableHead>{t('provider')}</TableHead>
<TableHead>{t('modelIdentifier')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredConfigs.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center h-24 text-zinc-500">
</TableCell>
<TableCell colSpan={5} className="text-center h-24 text-zinc-500">{t('noModelData')}</TableCell>
</TableRow>
) : (
filteredConfigs.map((item) => (
@@ -260,9 +256,9 @@ export function ModelConfigs() {
<span
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'}`}
title={item.is_active ? "当前默认模型" : "点击设为默认"}
title={item.is_active ? t('currentDefaultModel') : t('clickToSetDefault')}
>
{item.is_active ? '默认' : '设为默认'}
{item.is_active ? t('default') : t('setDefault')}
</span>
</TableCell>
<TableCell className="text-right">
@@ -296,18 +292,18 @@ export function ModelConfigs() {
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<form onSubmit={handleSave}>
<DialogHeader>
<DialogTitle>{editingId ? "编辑模型" : "添加模型"}</DialogTitle>
<DialogTitle>{editingId ? t('editModel') : t('addModel')}</DialogTitle>
</DialogHeader>
<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>}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="如:GPT-4" />
<Label>{t('modelName')}</Label>
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder={t('egGpt4')} />
</div>
<div className="space-y-2">
<Label> *</Label>
<Label>{t('providerRequired')}</Label>
<Select value={form.provider} onValueChange={(v) => setForm((p) => ({ ...p, provider: v || "openai" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent className="max-h-[300px]">
@@ -318,10 +314,10 @@ export function ModelConfigs() {
<SelectItem value="gemini">Google AI Studio (Gemini)</SelectItem>
<SelectItem value="bedrock">AWS Bedrock</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="dashscope">DashScope ()</SelectItem>
<SelectItem value="volcengine">Volcengine ()</SelectItem>
<SelectItem value="dashscope">{t('dashScope')}</SelectItem>
<SelectItem value="volcengine">{t('volcengine')}</SelectItem>
<SelectItem value="groq">Groq</SelectItem>
<SelectItem value="cohere">Cohere</SelectItem>
<SelectItem value="mistral">Mistral</SelectItem>
@@ -336,12 +332,12 @@ export function ModelConfigs() {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>ID *</Label>
<Input value={form.model || ""} onChange={(e) => setForm((p) => ({ ...p, model: e.target.value }))} placeholder="如:gpt-4-turbo" required />
<Label>{t('modelIdRequired')}</Label>
<Input value={form.model || ""} onChange={(e) => setForm((p) => ({ ...p, model: e.target.value }))} placeholder={t('egGpt4Turbo')} required />
</div>
<div className="space-y-2">
<Label>API </Label>
<Input value={form.api_base || ""} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="如:https://api.openai.com/v1" />
<Label>{t('apiDomain')}</Label>
<Input value={form.api_base || ""} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder={t('egApiDomain')} />
</div>
</div>
@@ -353,7 +349,7 @@ export function ModelConfigs() {
value={form.api_key || ""}
onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))}
className="pr-10"
placeholder="不修改请留空"
placeholder={t('leaveBlankIfNotModifying')}
/>
<button
type="button"
@@ -366,7 +362,7 @@ export function ModelConfigs() {
</div>
<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"}' />
</div>
</div>
@@ -376,7 +372,7 @@ export function ModelConfigs() {
</Button>
<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">
{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 { useProjectStore, type Project } from '@/store/projectStore';
import { Button } from '@/components/ui/button';
@@ -11,6 +12,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { useNavigate } from 'react-router-dom';
export function Projects() {
const { t } = useTranslation();
const { projects, loading, fetchProjects, addProject, updateProject, deleteProject, setCurrentProject } = useProjectStore();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
@@ -53,7 +55,7 @@ export function Projects() {
};
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 {
await deleteProject(id);
} 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="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">
<Folder className="h-5 w-5 text-blue-500" />
</div>
<Folder className="h-5 w-5 text-blue-500" />{t('projectManagement')}</div>
<Button onClick={() => {
setFormData({ name: '', description: '' });
setIsCreateDialogOpen(true);
}} size="sm" className="gap-2">
<Plus className="h-4 w-4" />
</Button>
<Plus className="h-4 w-4" />{t('newProject')}</Button>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="max-w-5xl mx-auto space-y-6">
<Card className="border-zinc-200 shadow-sm">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardTitle>{t('projectList')}</CardTitle>
<CardDescription>{t('manageProjectsDesc')}</CardDescription>
</CardHeader>
<CardContent>
{loading && projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-zinc-400">
<Loader2 className="h-8 w-8 animate-spin mb-4" />
<p>...</p>
<p>{t('loading')}</p>
</div>
) : projects.length === 0 ? (
<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" />
<p className="text-zinc-500"></p>
<p className="text-zinc-500">{t('noProjectsCreateOne')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t('name')}</TableHead>
<TableHead>{t('description')}</TableHead>
<TableHead>{t('createdAt')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -132,7 +130,7 @@ export function Projects() {
variant="ghost"
size="sm"
onClick={() => goToDataSources(project)}
title="管理数据源"
title={t('manageDataSources')}
>
<Database className="h-4 w-4 text-emerald-500" />
</Button>
@@ -140,7 +138,7 @@ export function Projects() {
variant="ghost"
size="sm"
onClick={() => openEditDialog(project)}
title="编辑项目"
title={t('editProject')}
>
<Pencil className="h-4 w-4 text-blue-500" />
</Button>
@@ -148,7 +146,7 @@ export function Projects() {
variant="ghost"
size="sm"
onClick={() => handleDelete(project.id)}
title="删除项目"
title={t('deleteProject')}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
@@ -168,32 +166,32 @@ export function Projects() {
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t('newProject')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">{t('projectName')}</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="输入项目名称"
placeholder={t('enterProjectName')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description"> ()</Label>
<Label htmlFor="description">{t('descriptionOptional')}</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="输入项目描述"
placeholder={t('enterProjectDescription')}
/>
</div>
</div>
<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()}>
{isSubmitting ? '创建中...' : '创建'}
{isSubmitting ? t('creating') : t('create')}
</Button>
</DialogFooter>
</DialogContent>
@@ -203,32 +201,32 @@ export function Projects() {
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t('editProject')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name"></Label>
<Label htmlFor="edit-name">{t('projectName')}</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="输入项目名称"
placeholder={t('enterProjectName')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description"> ()</Label>
<Label htmlFor="edit-description">{t('descriptionOptional')}</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="输入项目描述"
placeholder={t('enterProjectDescription')}
/>
</div>
</div>
<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()}>
{isSubmitting ? '保存中...' : '保存'}
{isSubmitting ? t('saving') : t('save')}
</Button>
</DialogFooter>
</DialogContent>
+16 -14
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -8,6 +9,7 @@ import { api } from "@/lib/api";
import { useAuthStore } from "@/store/authStore";
export function Settings() {
const { t } = useTranslation();
const { user, updateUser } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -28,7 +30,7 @@ export function Settings() {
setSuccess('');
if (isPasswordMismatch) {
setError("两次输入的密码不一致");
setError(t('passwordsDoNotMatch'));
return;
}
@@ -44,9 +46,9 @@ export function Settings() {
if (user && user.id) {
const response = await api.put<any>(`/api/v1/users/${user.id}`, updateData);
let successMsg = "个人设置保存成功!";
let successMsg = t('personalSettingsSaved');
if (password) {
successMsg = "个人设置及密码修改成功!";
successMsg = t('personalSettingsAndPasswordSaved');
}
setSuccess(successMsg);
setPassword('');
@@ -57,7 +59,7 @@ export function Settings() {
}
} catch (error: any) {
console.error("Failed to save settings", error);
setError(error.message || "保存设置失败");
setError(error.message || t('failedToSaveSettings'));
} finally {
setIsSaving(false);
}
@@ -79,23 +81,23 @@ export function Settings() {
<Card className="border-zinc-200 shadow-sm">
<CardHeader>
<CardTitle className="text-xl"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-xl">{t('accountInfo')}</CardTitle>
<CardDescription>{t('modifyLoginEmailAndPassword')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Label htmlFor="username">{t('username')}</Label>
<Input
id="username"
value={user?.username || ''}
disabled
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 className="space-y-2">
<Label htmlFor="email"></Label>
<Label htmlFor="email">{t('emailAddress')}</Label>
<Input
id="email"
type="email"
@@ -105,11 +107,11 @@ export function Settings() {
</div>
<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
id="new-password"
type="password"
placeholder="如不修改请留空"
placeholder={t('leaveBlankIfNotModifying')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
@@ -119,18 +121,18 @@ export function Settings() {
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password"></Label>
<Label htmlFor="confirm-password">{t('confirmNewPassword')}</Label>
<Input
id="confirm-password"
type="password"
placeholder="如不修改请留空"
placeholder={t('leaveBlankIfNotModifying')}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
setError('');
}}
/>
{isPasswordMismatch && <p className="text-sm text-red-600"></p>}
{isPasswordMismatch && <p className="text-sm text-red-600">{t('passwordsDoNotMatch')}</p>}
</div>
</CardContent>
<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 { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Trash2, Edit2, Plus, Terminal, Loader2, FolderOpen, Share2, Download, Eye, ShieldCheck, AlertCircle, Wand2, Upload } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
import { Trash2, Terminal, Loader2, FolderOpen, Eye, ShieldCheck, AlertCircle, Wand2, Upload } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -28,11 +27,12 @@ interface Skill {
}
export function Skills() {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
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 fileInputRef = useRef<HTMLInputElement>(null);
@@ -69,8 +69,8 @@ export function Skills() {
await fetchSkills();
} catch (error: any) {
console.error("Failed to upload skill", error);
const errorMessage = error.response?.data?.detail || error.message || "未知错误";
alert("上传失败: " + errorMessage);
const errorMessage = error.response?.data?.detail || error.message || t('unknownError');
alert(t('uploadFailed') + ': ' + errorMessage);
} finally {
setIsLoading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
@@ -95,7 +95,7 @@ export function Skills() {
await api.post<Skill>('/api/v1/skills', skillToCreate);
}
await fetchSkills();
setNewSkill({ type: 'python', content: '', source: '本地导入', status: '安全' });
setNewSkill({ type: 'python', content: '', source: t('localImport'), status: t('safe') });
setEditingSkill(null);
setIsDialogOpen(false);
} catch (error) {
@@ -112,7 +112,7 @@ export function Skills() {
const handleDeleteSkill = async (id: string) => {
if (!currentProject) return;
if (!window.confirm("确定要删除这个技能吗?")) return;
if (!window.confirm(t('confirmDeleteSkill'))) return;
try {
await api.delete(`/api/v1/skills/${id}?project_id=${currentProject.id}`);
setSkills(skills.filter(s => s.id !== id));
@@ -125,7 +125,7 @@ export function Skills() {
return (
<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" />
<p></p>
<p>{t('selectProjectToManageSkills')}</p>
</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>
<h1 className="text-2xl font-bold text-zinc-900 flex items-center gap-2">
< Wand2 className="h-6 w-6 text-indigo-500" />
Skills - {currentProject.name}
</h1>
<p className="text-sm text-zinc-500 mt-1"> AI agentskills.io </p>
< Wand2 className="h-6 w-6 text-indigo-500" />{t('skillsRepository', { project: currentProject.name })}</h1>
<p className="text-sm text-zinc-500 mt-1">{t('manageAiSkillsDesc')}</p>
</div>
<div className="flex gap-3">
<input
@@ -152,9 +150,7 @@ export function Skills() {
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
Skill
</Button>
<Upload className="h-4 w-4" />{t('uploadSkill')}</Button>
</div>
</div>
@@ -163,11 +159,11 @@ export function Skills() {
<Table className="table-fixed w-full">
<TableHeader className="bg-zinc-50/50">
<TableRow className="hover:bg-transparent">
<TableHead className="w-[40%] 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"></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"></TableHead>
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-right"></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">{t('source')}</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">{t('status')}</TableHead>
<TableHead className="w-[15%] font-semibold text-zinc-700 py-3 px-4 text-sm text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -214,11 +210,11 @@ export function Skills() {
</TableCell>
<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 ${
skill.status === '安全'
skill.status === t('safe')
? 'bg-green-50 text-green-700 border border-green-100'
: 'bg-amber-50 text-amber-700 border border-amber-100'
}`}>
{skill.status === '安全' ? (
{skill.status === t('safe') ? (
<ShieldCheck 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">
<Terminal className="h-10 w-10 opacity-20" />
</div>
<p className="text-sm"> Skill</p>
<p className="text-sm">{t('noSkillsInProjectClickImport')}</p>
</div>
</TableCell>
</TableRow>
@@ -275,20 +271,20 @@ export function Skills() {
setIsDialogOpen(open);
if (!open) {
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">
<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>
<div className="flex-1 overflow-y-auto px-6 py-2">
<div className="grid gap-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
id="name"
placeholder="技能名称"
placeholder={t('skillName')}
value={newSkill.name || ''}
onChange={(e) => setNewSkill({...newSkill, name: e.target.value})}
className="rounded-lg border-zinc-200 h-10"
@@ -297,14 +293,14 @@ export function Skills() {
</div>
<div className="grid grid-cols-2 gap-4">
<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
value={newSkill.type}
onValueChange={(val: any) => setNewSkill({...newSkill, type: val})}
disabled={editingSkill?.is_builtin}
>
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
<SelectValue placeholder="选择类型" />
<SelectValue placeholder={t('selectType')} />
</SelectTrigger>
<SelectContent className="rounded-lg">
<SelectItem value="python">Python</SelectItem>
@@ -314,27 +310,27 @@ export function Skills() {
</Select>
</div>
<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
value={newSkill.status}
onValueChange={(val: any) => setNewSkill({...newSkill, status: val})}
disabled={editingSkill?.is_builtin}
>
<SelectTrigger className="rounded-lg border-zinc-200 h-10">
<SelectValue placeholder="选择状态" />
<SelectValue placeholder={t('selectStatus')} />
</SelectTrigger>
<SelectContent className="rounded-lg">
<SelectItem value="安全"></SelectItem>
<SelectItem value="低风险"></SelectItem>
<SelectItem value={t('safe')}>{t('safe')}</SelectItem>
<SelectItem value={t('lowRisk')}>{t('lowRisk')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<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
id="description"
placeholder="简要描述技能的功能..."
placeholder={t('brieflyDescribeSkillFunction')}
value={newSkill.description || ''}
onChange={(e) => setNewSkill({...newSkill, description: e.target.value})}
className="rounded-lg border-zinc-200 min-h-[80px] py-2 text-sm"
@@ -342,13 +338,13 @@ export function Skills() {
/>
</div>
<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
id="content"
value={newSkill.content || ''}
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"
placeholder="Python 代码、SQL 查询模板或 API 规范..."
placeholder={t('pythonSqlApiContentPlaceholder')}
disabled={editingSkill?.is_builtin}
/>
</div>
@@ -356,9 +352,7 @@ export function Skills() {
</div>
<DialogFooter className="p-6 pt-2">
{!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>
<Button onClick={handleAddSkill} className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-6 h-10 w-full">{t('saveSkill')}</Button>
)}
</DialogFooter>
</DialogContent>
+22 -20
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -18,6 +19,7 @@ interface User {
}
export function Users() {
const { t } = useTranslation();
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -87,7 +89,7 @@ export function Users() {
} else {
// Create
if (!formData.password) {
setError("新建用户必须填写密码");
setError(t('newUserMustHavePassword'));
return;
}
await api.post("/api/v1/users", formData);
@@ -95,12 +97,12 @@ export function Users() {
setIsDialogOpen(false);
fetchUsers();
} catch (err: any) {
setError(err.message || "发生错误");
setError(err.message || t('anErrorOccurred'));
}
};
const handleDelete = async (id: number) => {
if (window.confirm("确认删除该用户吗?")) {
if (window.confirm(t('confirmDeleteUser'))) {
try {
await api.delete(`/api/v1/users/${id}`);
fetchUsers();
@@ -125,12 +127,12 @@ export function Users() {
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{editingUser ? "编辑用户" : "添加新用户"}</DialogTitle>
<DialogTitle>{editingUser ? t('editUser') : t('addNewUser')}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{error && <div className="text-red-500 text-sm">{error}</div>}
<div className="grid gap-2">
<Label htmlFor="username"></Label>
<Label htmlFor="username">{t('username')}</Label>
<Input
id="username"
value={formData.username}
@@ -139,7 +141,7 @@ export function Users() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email"></Label>
<Label htmlFor="email">{t('email')}</Label>
<Input
id="email"
type="email"
@@ -150,7 +152,7 @@ export function Users() {
</div>
{!editingUser && (
<div className="grid gap-2">
<Label htmlFor="password"></Label>
<Label htmlFor="password">{t('password')}</Label>
<Input
id="password"
type="password"
@@ -161,7 +163,7 @@ export function Users() {
</div>
)}
<div className="flex items-center justify-between">
<Label htmlFor="is_active"></Label>
<Label htmlFor="is_active">{t('activeStatus')}</Label>
<Switch
id="is_active"
checked={formData.is_active}
@@ -169,7 +171,7 @@ export function Users() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="is_admin"></Label>
<Label htmlFor="is_admin">{t('adminPrivileges')}</Label>
<Switch
id="is_admin"
checked={formData.is_admin}
@@ -179,10 +181,10 @@ export function Users() {
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
{t('cancel')}
</Button>
<Button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white">
{t('save')}
</Button>
</DialogFooter>
</form>
@@ -200,13 +202,13 @@ export function Users() {
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t('id')}</TableHead>
<TableHead>{t('username')}</TableHead>
<TableHead>{t('email')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead>{t('role')}</TableHead>
<TableHead>{t('createdAt')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -224,12 +226,12 @@ export function Users() {
<TableCell>{user.email}</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'}`}>
{user.is_active ? '正常' : '禁用'}
{user.is_active ? t('normal') : t('disabled')}
</span>
</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'}`}>
{user.is_admin ? '管理员' : '普通用户'}
{user.is_admin ? t('admin') : t('regularUser')}
</span>
</TableCell>
<TableCell className="text-zinc-500">