mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
feat(ui): i18n for projects pages and modals
This commit is contained in:
parent
8a3335494d
commit
7298777feb
9 changed files with 402 additions and 159 deletions
|
|
@ -19,6 +19,7 @@ import { LoadingOutlined } from "@ant-design/icons";
|
|||
import { BarChart } from "@tremor/react";
|
||||
import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { EditProjectModal } from "./ProjectModals/EditProjectModal";
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ interface ProjectDetailProps {
|
|||
}
|
||||
|
||||
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: project, isLoading } = useProjectDetails(projectId);
|
||||
const { data: teamData } = useTeam(project?.team_id ?? undefined);
|
||||
// teamInfoCall returns { team_id, team_info: {...}, keys, team_memberships }
|
||||
|
|
@ -86,7 +88,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
}}
|
||||
>
|
||||
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" style={{ marginBottom: 16 }} />
|
||||
<Empty description="Project not found" />
|
||||
<Empty description={t("projects.projectDetailsPage.notFound")} />
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
|
@ -109,7 +111,9 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
<Title level={2} style={{ margin: 0 }}>
|
||||
{project.project_alias ?? project.project_id}
|
||||
</Title>
|
||||
<Tag color={project.blocked ? "red" : "green"}>{project.blocked ? "Blocked" : "Active"}</Tag>
|
||||
<Tag color={project.blocked ? "red" : "green"}>
|
||||
{project.blocked ? t("projects.projectDetailsPage.statusBlocked") : t("common.active")}
|
||||
</Tag>
|
||||
</Flex>
|
||||
<Text type="secondary">
|
||||
ID: <Text copyable>{project.project_id}</Text>
|
||||
|
|
@ -117,29 +121,29 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<EditIcon size={16} />} onClick={() => setIsEditModalVisible(true)}>
|
||||
Edit Project
|
||||
{t("projects.projectDetailsPage.editProject")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Project Details */}
|
||||
<Row style={{ marginBottom: 24 }}>
|
||||
<Card>
|
||||
<Descriptions title="Project Details" column={1}>
|
||||
<Descriptions.Item label="Description">{project.description || "\u2014"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Created">
|
||||
<Descriptions title={t("projects.projectDetailsPage.detailsTitle")} column={1}>
|
||||
<Descriptions.Item label={t("common.description")}>{project.description || "\u2014"}</Descriptions.Item>
|
||||
<Descriptions.Item label={t("common.createdAt")}>
|
||||
{new Date(project.created_at).toLocaleString()}
|
||||
{project.created_by && (
|
||||
<Text>
|
||||
{"by"}
|
||||
{t("projects.projectDetailsPage.by")}
|
||||
<DefaultProxyAdminTag userId={project.created_by} />
|
||||
</Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Updated">
|
||||
<Descriptions.Item label={t("common.updatedAt")}>
|
||||
{new Date(project.updated_at).toLocaleString()}
|
||||
{project.updated_by && (
|
||||
<Text>
|
||||
{"by"}
|
||||
{t("projects.projectDetailsPage.by")}
|
||||
<DefaultProxyAdminTag userId={project.updated_by} />
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -155,7 +159,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<DollarSignIcon size={16} />
|
||||
Budget
|
||||
{t("projects.projectDetailsPage.budgetTitle")}
|
||||
</Flex>
|
||||
}
|
||||
style={{ height: "100%" }}
|
||||
|
|
@ -166,13 +170,19 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
${spend.toFixed(2)}
|
||||
</Text>
|
||||
<br />
|
||||
<Text type="secondary">{hasLimit ? `of $${maxBudget.toFixed(2)} budget` : "No budget limit"}</Text>
|
||||
<Text type="secondary">
|
||||
{hasLimit
|
||||
? t("projects.projectDetailsPage.budgetOf", { budget: maxBudget.toFixed(2) })
|
||||
: t("projects.projectDetailsPage.noBudgetLimit")}
|
||||
</Text>
|
||||
</div>
|
||||
{hasLimit && (
|
||||
<div>
|
||||
<Progress percent={Math.round(spendPercent * 10) / 10} strokeColor={spendColor} showInfo={false} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized
|
||||
{t("projects.projectDetailsPage.utilized", {
|
||||
percent: (Math.round(spendPercent * 10) / 10).toFixed(1),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -180,7 +190,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="Spend by Model" style={{ height: "100%" }}>
|
||||
<Card title={t("projects.projectDetailsPage.spendByModel")} style={{ height: "100%" }}>
|
||||
{modelSpendData.length > 0 ? (
|
||||
<BarChart
|
||||
data={modelSpendData}
|
||||
|
|
@ -194,7 +204,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
style={{ height: Math.max(modelSpendData.length * 40, 120) }}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="No model spend recorded yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
<Empty description={t("projects.projectDetailsPage.noModelSpend")} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
@ -207,12 +217,15 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<KeyIcon size={16} />
|
||||
Keys
|
||||
{t("projects.projectDetailsPage.keysTitle")}
|
||||
</Flex>
|
||||
}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Empty description="No keys to display" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
<Empty
|
||||
description={t("projects.projectDetailsPage.noKeysToDisplay")}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
|
|
@ -220,7 +233,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<UsersIcon size={16} />
|
||||
Team
|
||||
{t("projects.projectDetailsPage.teamTitle")}
|
||||
</Flex>
|
||||
}
|
||||
style={{ height: "100%" }}
|
||||
|
|
@ -252,7 +265,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
{/* Models */}
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>
|
||||
Models
|
||||
{t("projects.projectDetailsPage.modelsLabel")}
|
||||
</Text>
|
||||
{(teamInfo.models?.length ?? 0) > 0 ? (
|
||||
<Flex wrap="wrap" gap={4} style={{ maxHeight: 60, overflow: "hidden" }}>
|
||||
|
|
@ -263,7 +276,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Text type="secondary">All models</Text>
|
||||
<Text type="secondary">{t("projects.projectDetailsPage.allModels")}</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -271,7 +284,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
<div>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 2 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Spend
|
||||
{t("projects.projectDetailsPage.spendLabel")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
${teamSpend.toFixed(2)}
|
||||
|
|
@ -283,7 +296,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{" "}
|
||||
(Unlimited)
|
||||
({t("projects.projectDetailsPage.unlimited")})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
|
@ -301,7 +314,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
{/* Members */}
|
||||
<Flex justify="space-between">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Members
|
||||
{t("projects.projectDetailsPage.membersLabel")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12 }}>{teamInfo.members_with_roles?.length ?? 0}</Text>
|
||||
</Flex>
|
||||
|
|
@ -313,7 +326,10 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
|
|||
<Spin indicator={<LoadingOutlined spin />} size="small" />
|
||||
</Flex>
|
||||
) : (
|
||||
<Empty description="No team assigned" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
<Empty
|
||||
description={t("projects.projectDetailsPage.noTeamAssigned")}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { LoadingOutlined } from "@ant-design/icons";
|
|||
import { Card, Flex, Input, Pagination, Spin } from "antd";
|
||||
import { KeyIcon, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ProjectKeysTable } from "./ProjectKeysTable";
|
||||
|
||||
interface ProjectKeysSectionProps {
|
||||
|
|
@ -12,6 +13,7 @@ interface ProjectKeysSectionProps {
|
|||
const PAGE_SIZE = 5;
|
||||
|
||||
export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyAlias, setKeyAlias] = useState<string>("");
|
||||
|
||||
|
|
@ -33,7 +35,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
|
|||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<KeyIcon size={16} />
|
||||
Keys
|
||||
{t("projects.projectKeysSection.title")}
|
||||
</Flex>
|
||||
}
|
||||
style={{ height: "100%" }}
|
||||
|
|
@ -41,7 +43,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
|
|||
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
prefix={<SearchIcon size={14} />}
|
||||
placeholder="Filter by key name..."
|
||||
placeholder={t("projects.projectKeysSection.filterPlaceholder")}
|
||||
style={{ maxWidth: 220 }}
|
||||
value={keyAlias}
|
||||
onChange={(e) => setKeyAlias(e.target.value)}
|
||||
|
|
@ -55,7 +57,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
|
|||
onChange={setPage}
|
||||
size="small"
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `${total} keys`}
|
||||
showTotal={(total) => t("projects.projectKeysSection.totalKeys", { count: total })}
|
||||
/>
|
||||
</Flex>
|
||||
<ProjectKeysTable
|
||||
|
|
|
|||
|
|
@ -3,21 +3,24 @@ import { Empty, Table, Tooltip } from "antd";
|
|||
import type { ColumnsType } from "antd/es/table";
|
||||
import type { SpinProps } from "antd";
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ProjectKeysTableProps {
|
||||
keys: KeyResponse[];
|
||||
loading?: boolean | SpinProps;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<KeyResponse> = [
|
||||
const getProjectKeysTableColumns = (t: TFunction): ColumnsType<KeyResponse> => [
|
||||
{
|
||||
title: "Key Name",
|
||||
title: t("projects.projectKeysTable.colKeyName"),
|
||||
dataIndex: "key_alias",
|
||||
key: "key_alias",
|
||||
render: (alias: string | null) => alias || "—",
|
||||
},
|
||||
{
|
||||
title: "Owner",
|
||||
title: t("projects.projectKeysTable.colOwner"),
|
||||
key: "owner",
|
||||
render: (_: unknown, record: KeyResponse) => {
|
||||
const email = record.user?.user_email ?? record.user_id ?? null;
|
||||
|
|
@ -30,20 +33,23 @@ const columns: ColumnsType<KeyResponse> = [
|
|||
},
|
||||
},
|
||||
{
|
||||
title: "Created",
|
||||
title: t("common.createdAt"),
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"),
|
||||
},
|
||||
{
|
||||
title: "Last Active",
|
||||
title: t("projects.projectKeysTable.colLastActive"),
|
||||
dataIndex: "last_active",
|
||||
key: "last_active",
|
||||
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"),
|
||||
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : t("common.never")),
|
||||
},
|
||||
];
|
||||
|
||||
export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo(() => getProjectKeysTableColumns(t), [t]);
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
|
|
@ -52,7 +58,11 @@ export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) {
|
|||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: <Empty description="No keys found" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty description={t("projects.projectKeysTable.noKeysFound")} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Modal, Form, Button, Typography } from "antd";
|
|||
import { FolderAddOutlined } from "@ant-design/icons";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { useCreateProject, ProjectCreateParams } from "@/app/(dashboard)/hooks/projects/useCreateProject";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
|
||||
import { buildProjectApiParams } from "./projectFormUtils";
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ interface CreateProjectModalProps {
|
|||
}
|
||||
|
||||
export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm<ProjectFormValues>();
|
||||
const createMutation = useCreateProject();
|
||||
|
||||
|
|
@ -24,12 +26,12 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
|
|||
|
||||
createMutation.mutate(params, {
|
||||
onSuccess: () => {
|
||||
MessageManager.success("Project created successfully");
|
||||
MessageManager.success(t("projects.createProjectModal.createSuccess"));
|
||||
form.resetFields();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
MessageManager.error(error.message || "Failed to create project");
|
||||
MessageManager.error(error.message || t("projects.createProjectModal.createFailed"));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -46,7 +48,7 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
|
|||
<Modal
|
||||
title={
|
||||
<Typography.Text strong style={{ fontSize: 18 }}>
|
||||
Create New Project
|
||||
{t("projects.createProjectModal.title")}
|
||||
</Typography.Text>
|
||||
}
|
||||
open={isOpen}
|
||||
|
|
@ -55,7 +57,7 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
|
|||
destroyOnHidden
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
|
|
@ -64,7 +66,7 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
|
|||
loading={createMutation.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Create Project
|
||||
{t("projects.createProjectModal.createBtn")}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { SaveOutlined } from "@ant-design/icons";
|
|||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
import { useUpdateProject, ProjectUpdateParams } from "@/app/(dashboard)/hooks/projects/useUpdateProject";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
|
||||
import { buildProjectApiParams } from "./projectFormUtils";
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ interface EditProjectModalProps {
|
|||
}
|
||||
|
||||
export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditProjectModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm<ProjectFormValues>();
|
||||
const updateMutation = useUpdateProject();
|
||||
|
||||
|
|
@ -72,12 +74,12 @@ export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditPr
|
|||
{ projectId: project.project_id, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
MessageManager.success("Project updated successfully");
|
||||
MessageManager.success(t("projects.editProjectModal.updateSuccess"));
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
MessageManager.error(error.message || "Failed to update project");
|
||||
MessageManager.error(error.message || t("projects.editProjectModal.updateFailed"));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -90,7 +92,7 @@ export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditPr
|
|||
<Modal
|
||||
title={
|
||||
<Typography.Text strong style={{ fontSize: 18 }}>
|
||||
Edit Project
|
||||
{t("projects.editProjectModal.title")}
|
||||
</Typography.Text>
|
||||
}
|
||||
open={isOpen}
|
||||
|
|
@ -99,7 +101,7 @@ export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditPr
|
|||
destroyOnHidden
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
|
|
@ -108,7 +110,7 @@ export function EditProjectModal({ isOpen, project, onClose, onSuccess }: EditPr
|
|||
loading={updateMutation.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Save Changes
|
||||
{t("projects.editProjectModal.saveBtn")}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import type { FormInstance } from "antd";
|
||||
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { Team } from "../../key_team_helpers/key_list";
|
||||
import { fetchTeamModels } from "../../organisms/create_key_button";
|
||||
|
|
@ -41,6 +42,7 @@ interface ProjectBaseFormProps {
|
|||
}
|
||||
|
||||
export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken, userId, userRole } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
|
||||
|
|
@ -109,7 +111,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
Basic Information
|
||||
{t("projects.projectBaseForm.basicInfoTitle")}
|
||||
</Typography.Text>
|
||||
<Divider style={{ marginTop: 8, marginBottom: 16 }} />
|
||||
|
||||
|
|
@ -117,17 +119,21 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="project_alias"
|
||||
label="Project Name"
|
||||
rules={[{ required: true, message: "Please enter a project name" }]}
|
||||
label={t("projects.projectBaseForm.projectNameLabel")}
|
||||
rules={[{ required: true, message: t("projects.projectBaseForm.projectNameRequired") }]}
|
||||
>
|
||||
<Input placeholder="e.g. Customer Support Bot" />
|
||||
<Input placeholder={t("projects.projectBaseForm.projectNamePlaceholder")} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="team_id" label="Team" rules={[{ required: true, message: "Please select a team" }]}>
|
||||
<Form.Item
|
||||
name="team_id"
|
||||
label={t("projects.projectBaseForm.teamLabel")}
|
||||
rules={[{ required: true, message: t("projects.projectBaseForm.teamRequired") }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select a team"
|
||||
placeholder={t("projects.projectBaseForm.teamPlaceholder")}
|
||||
onChange={handleTeamChange}
|
||||
allowClear
|
||||
optionLabelProp="label"
|
||||
|
|
@ -153,8 +159,8 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea placeholder="Describe the purpose of this project" rows={3} />
|
||||
<Form.Item name="description" label={t("common.description")}>
|
||||
<Input.TextArea placeholder={t("projects.projectBaseForm.descriptionPlaceholder")} rows={3} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -163,12 +169,16 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="models"
|
||||
label="Allowed Models (scoped to selected team's models)"
|
||||
help={!selectedTeam ? "Select a team first to see available models" : undefined}
|
||||
label={t("projects.projectBaseForm.allowedModelsLabel")}
|
||||
help={!selectedTeam ? t("projects.projectBaseForm.selectTeamFirst") : undefined}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={selectedTeam ? "Select models" : "Select a team first"}
|
||||
placeholder={
|
||||
selectedTeam
|
||||
? t("projects.projectBaseForm.selectModels")
|
||||
: t("projects.projectBaseForm.selectTeamFirstPlaceholder")
|
||||
}
|
||||
disabled={!selectedTeam}
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
|
|
@ -179,7 +189,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
}}
|
||||
>
|
||||
<Select.Option key="all-team-models" value="all-team-models">
|
||||
All Team Models
|
||||
{t("projects.projectBaseForm.allTeamModels")}
|
||||
</Select.Option>
|
||||
{modelsToPick.map((model) => (
|
||||
<Select.Option key={model} value={model}>
|
||||
|
|
@ -193,7 +203,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="max_budget" label="Max Budget (USD)">
|
||||
<Form.Item name="max_budget" label={t("projects.projectBaseForm.maxBudgetLabel")}>
|
||||
<InputNumber prefix="$" style={{ width: "100%" }} placeholder="0.00" min={0} precision={2} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
|
@ -214,13 +224,13 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
key: "1",
|
||||
label: (
|
||||
<Typography.Text strong style={{ color: "#374151" }}>
|
||||
Advanced Settings
|
||||
{t("projects.projectBaseForm.advancedSettingsTitle")}
|
||||
</Typography.Text>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<Flex align="center" gap={12}>
|
||||
<Typography.Text strong>Block Project</Typography.Text>
|
||||
<Typography.Text strong>{t("projects.projectBaseForm.blockProjectLabel")}</Typography.Text>
|
||||
<Form.Item name="isBlocked" valuePropName="checked" noStyle>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
|
@ -232,7 +242,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
banner
|
||||
type="warning"
|
||||
showIcon
|
||||
message="All API requests using keys under this project will be rejected."
|
||||
message={t("projects.projectBaseForm.blockProjectWarning")}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
) : null
|
||||
|
|
@ -241,11 +251,15 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
|
||||
<Divider />
|
||||
|
||||
<Form.Item label="Guardrails" name="guardrails" help="Select existing guardrails or enter new ones">
|
||||
<Form.Item
|
||||
label={t("projects.projectBaseForm.guardrailsLabel")}
|
||||
name="guardrails"
|
||||
help={t("projects.projectBaseForm.guardrailsHelp")}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Select or enter guardrails"
|
||||
placeholder={t("projects.projectBaseForm.guardrailsPlaceholder")}
|
||||
options={guardrailsList.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
|
|
@ -256,7 +270,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
<Divider />
|
||||
|
||||
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
|
||||
Model-Specific Limits
|
||||
{t("projects.projectBaseForm.modelLimitsTitle")}
|
||||
</Typography.Text>
|
||||
<Form.List name="modelLimits">
|
||||
{(fields, { add, remove }) => (
|
||||
|
|
@ -267,34 +281,34 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
{...restField}
|
||||
name={[name, "model"]}
|
||||
rules={[
|
||||
{ required: true, message: "Missing model" },
|
||||
{ required: true, message: t("projects.projectBaseForm.missingModel") },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const all = form.getFieldValue("modelLimits") ?? [];
|
||||
const dupes = all.filter((entry: { model?: string }) => entry?.model === value);
|
||||
if (dupes.length > 1) {
|
||||
return Promise.reject(new Error("Duplicate model"));
|
||||
return Promise.reject(new Error(t("projects.projectBaseForm.duplicateModel")));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="Model name (e.g. gpt-4)" />
|
||||
<Input placeholder={t("projects.projectBaseForm.modelNamePlaceholder")} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, "tpm"]}>
|
||||
<InputNumber placeholder="TPM Limit" min={0} />
|
||||
<InputNumber placeholder={t("projects.projectBaseForm.tpmPlaceholder")} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, "rpm"]}>
|
||||
<InputNumber placeholder="RPM Limit" min={0} />
|
||||
<InputNumber placeholder={t("projects.projectBaseForm.rpmPlaceholder")} min={0} />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
Add Model Limit
|
||||
{t("projects.projectBaseForm.addModelLimit")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
|
@ -304,7 +318,7 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
<Divider />
|
||||
|
||||
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
|
||||
Metadata
|
||||
{t("projects.projectBaseForm.metadataTitle")}
|
||||
</Typography.Text>
|
||||
<Form.List name="metadata">
|
||||
{(fields, { add, remove }) => (
|
||||
|
|
@ -315,35 +329,35 @@ export function ProjectBaseForm({ form }: ProjectBaseFormProps) {
|
|||
{...restField}
|
||||
name={[name, "key"]}
|
||||
rules={[
|
||||
{ required: true, message: "Missing key" },
|
||||
{ required: true, message: t("projects.projectBaseForm.missingKey") },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const all = form.getFieldValue("metadata") ?? [];
|
||||
const dupes = all.filter((entry: { key?: string }) => entry?.key === value);
|
||||
if (dupes.length > 1) {
|
||||
return Promise.reject(new Error("Duplicate key"));
|
||||
return Promise.reject(new Error(t("projects.projectBaseForm.duplicateKey")));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="Key" />
|
||||
<Input placeholder={t("projects.projectBaseForm.keyPlaceholder")} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, "value"]}
|
||||
rules={[{ required: true, message: "Missing value" }]}
|
||||
rules={[{ required: true, message: t("projects.projectBaseForm.missingValue") }]}
|
||||
>
|
||||
<Input placeholder="Value" />
|
||||
<Input placeholder={t("projects.projectBaseForm.valuePlaceholder")} />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
Add Key-Value Pair
|
||||
{t("projects.projectBaseForm.addKeyValue")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import type { ColumnsType } from "antd/es/table";
|
||||
import { LayersIcon, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
|
||||
import { ProjectDetail } from "./ProjectDetailsPage";
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ const { Title, Text } = Typography;
|
|||
const { Content } = Layout;
|
||||
|
||||
export function ProjectsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const { data: projects, isLoading } = useProjects();
|
||||
const { data: teams, isLoading: isTeamsLoading } = useTeams();
|
||||
|
|
@ -66,87 +68,94 @@ export function ProjectsPage() {
|
|||
}, [projects, searchText, teamAliasMap]);
|
||||
|
||||
// ---------- Ant Design columns ----------
|
||||
const columns: ColumnsType<ProjectResponse> = [
|
||||
{
|
||||
title: "ID",
|
||||
dataIndex: "project_id",
|
||||
key: "project_id",
|
||||
width: 170,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<Text
|
||||
ellipsis
|
||||
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
|
||||
style={{ fontSize: 14, padding: "1px 8px" }}
|
||||
onClick={() => setSelectedProjectId(id)}
|
||||
>
|
||||
{id}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "project_alias",
|
||||
key: "project_alias",
|
||||
sorter: (a, b) => (a.project_alias ?? "").localeCompare(b.project_alias ?? ""),
|
||||
render: (alias: string | null) => alias ?? "—",
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
key: "team",
|
||||
sorter: (a, b) => {
|
||||
const aAlias = teamAliasMap.get(a.team_id ?? "") ?? "";
|
||||
const bAlias = teamAliasMap.get(b.team_id ?? "") ?? "";
|
||||
return aAlias.localeCompare(bAlias);
|
||||
},
|
||||
render: (_: unknown, record: ProjectResponse) => {
|
||||
if (!record.team_id) return "—";
|
||||
const alias = teamAliasMap.get(record.team_id);
|
||||
if (alias) return alias;
|
||||
if (isTeamsLoading) return <Spin indicator={<LoadingOutlined spin />} size="small" />;
|
||||
return record.team_id;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Models",
|
||||
key: "models",
|
||||
render: (_: unknown, record: ProjectResponse) => {
|
||||
const models = record.models ?? [];
|
||||
return (
|
||||
<Tooltip title={models.length > 0 ? models.join(", ") : "No models"}>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
|
||||
<Flex align="center" gap={6}>
|
||||
<LayersIcon size={14} />
|
||||
{models.length}
|
||||
</Flex>
|
||||
</Tag>
|
||||
const columns: ColumnsType<ProjectResponse> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "ID",
|
||||
dataIndex: "project_id",
|
||||
key: "project_id",
|
||||
width: 170,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<Text
|
||||
ellipsis
|
||||
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
|
||||
style={{ fontSize: 14, padding: "1px 8px" }}
|
||||
onClick={() => setSelectedProjectId(id)}
|
||||
>
|
||||
{id}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "blocked",
|
||||
key: "status",
|
||||
render: (blocked: boolean) => <Tag color={blocked ? "red" : "green"}>{blocked ? "Blocked" : "Active"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "Created",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
responsive: ["lg"],
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: "Updated",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
responsive: ["xl"],
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
];
|
||||
{
|
||||
title: t("common.name"),
|
||||
dataIndex: "project_alias",
|
||||
key: "project_alias",
|
||||
sorter: (a, b) => (a.project_alias ?? "").localeCompare(b.project_alias ?? ""),
|
||||
render: (alias: string | null) => alias ?? "—",
|
||||
},
|
||||
{
|
||||
title: t("projects.projectsPage.colTeam"),
|
||||
key: "team",
|
||||
sorter: (a, b) => {
|
||||
const aAlias = teamAliasMap.get(a.team_id ?? "") ?? "";
|
||||
const bAlias = teamAliasMap.get(b.team_id ?? "") ?? "";
|
||||
return aAlias.localeCompare(bAlias);
|
||||
},
|
||||
render: (_: unknown, record: ProjectResponse) => {
|
||||
if (!record.team_id) return "—";
|
||||
const alias = teamAliasMap.get(record.team_id);
|
||||
if (alias) return alias;
|
||||
if (isTeamsLoading) return <Spin indicator={<LoadingOutlined spin />} size="small" />;
|
||||
return record.team_id;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("projects.projectsPage.colModels"),
|
||||
key: "models",
|
||||
render: (_: unknown, record: ProjectResponse) => {
|
||||
const models = record.models ?? [];
|
||||
return (
|
||||
<Tooltip title={models.length > 0 ? models.join(", ") : t("projects.projectsPage.noModels")}>
|
||||
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
|
||||
<Flex align="center" gap={6}>
|
||||
<LayersIcon size={14} />
|
||||
{models.length}
|
||||
</Flex>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("common.status"),
|
||||
dataIndex: "blocked",
|
||||
key: "status",
|
||||
render: (blocked: boolean) => (
|
||||
<Tag color={blocked ? "red" : "green"}>
|
||||
{blocked ? t("projects.projectsPage.statusBlocked") : t("common.active")}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("common.createdAt"),
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
responsive: ["lg"],
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: t("common.updatedAt"),
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
responsive: ["xl"],
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
],
|
||||
[t, teamAliasMap, isTeamsLoading, setSelectedProjectId],
|
||||
);
|
||||
|
||||
if (selectedProjectId) {
|
||||
return <ProjectDetail projectId={selectedProjectId} onBack={() => setSelectedProjectId(null)} />;
|
||||
|
|
@ -157,12 +166,12 @@ export function ProjectsPage() {
|
|||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Projects
|
||||
{t("projects.projectsPage.title")}
|
||||
</Title>
|
||||
<Text type="secondary">Manage projects within your teams</Text>
|
||||
<Text type="secondary">{t("projects.projectsPage.subtitle")}</Text>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsCreateModalVisible(true)}>
|
||||
Create Project
|
||||
{t("projects.projectsPage.createBtn")}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
|
|
@ -170,7 +179,7 @@ export function ProjectsPage() {
|
|||
<Flex justify="space-between" align="center" style={{ padding: "12px 16px" }}>
|
||||
<Input
|
||||
prefix={<SearchIcon size={16} />}
|
||||
placeholder="Search projects by name, ID, description, or team..."
|
||||
placeholder={t("projects.projectsPage.searchPlaceholder")}
|
||||
style={{ maxWidth: 400 }}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
|
|
@ -182,7 +191,7 @@ export function ProjectsPage() {
|
|||
pageSize={pageSize}
|
||||
onChange={(page) => setCurrentPage(page)}
|
||||
size="small"
|
||||
showTotal={(total) => `${total} projects`}
|
||||
showTotal={(total) => t("projects.projectsPage.totalProjects", { count: total })}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -6690,5 +6690,99 @@
|
|||
"successCount_other": "Successfully made {{count}} model groups public!",
|
||||
"failedToMakePublic": "Failed to make model groups public. Please try again."
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"projectDetailsPage": {
|
||||
"notFound": "Project not found",
|
||||
"statusBlocked": "Blocked",
|
||||
"editProject": "Edit Project",
|
||||
"detailsTitle": "Project Details",
|
||||
"by": "by",
|
||||
"budgetTitle": "Budget",
|
||||
"budgetOf": "of ${{budget}} budget",
|
||||
"noBudgetLimit": "No budget limit",
|
||||
"utilized": "{{percent}}% utilized",
|
||||
"spendByModel": "Spend by Model",
|
||||
"noModelSpend": "No model spend recorded yet",
|
||||
"keysTitle": "Keys",
|
||||
"noKeysToDisplay": "No keys to display",
|
||||
"teamTitle": "Team",
|
||||
"modelsLabel": "Models",
|
||||
"allModels": "All models",
|
||||
"spendLabel": "Spend",
|
||||
"unlimited": "Unlimited",
|
||||
"membersLabel": "Members",
|
||||
"noTeamAssigned": "No team assigned"
|
||||
},
|
||||
"projectKeysSection": {
|
||||
"title": "Keys",
|
||||
"filterPlaceholder": "Filter by key name...",
|
||||
"totalKeys": "{{count}} keys"
|
||||
},
|
||||
"projectKeysTable": {
|
||||
"colKeyName": "Key Name",
|
||||
"colOwner": "Owner",
|
||||
"colLastActive": "Last Active",
|
||||
"noKeysFound": "No keys found"
|
||||
},
|
||||
"createProjectModal": {
|
||||
"title": "Create New Project",
|
||||
"createBtn": "Create Project",
|
||||
"createSuccess": "Project created successfully",
|
||||
"createFailed": "Failed to create project"
|
||||
},
|
||||
"editProjectModal": {
|
||||
"title": "Edit Project",
|
||||
"saveBtn": "Save Changes",
|
||||
"updateSuccess": "Project updated successfully",
|
||||
"updateFailed": "Failed to update project"
|
||||
},
|
||||
"projectBaseForm": {
|
||||
"basicInfoTitle": "Basic Information",
|
||||
"projectNameLabel": "Project Name",
|
||||
"projectNameRequired": "Please enter a project name",
|
||||
"projectNamePlaceholder": "e.g. Customer Support Bot",
|
||||
"teamLabel": "Team",
|
||||
"teamRequired": "Please select a team",
|
||||
"teamPlaceholder": "Search or select a team",
|
||||
"descriptionPlaceholder": "Describe the purpose of this project",
|
||||
"allowedModelsLabel": "Allowed Models (scoped to selected team's models)",
|
||||
"selectTeamFirst": "Select a team first to see available models",
|
||||
"selectModels": "Select models",
|
||||
"selectTeamFirstPlaceholder": "Select a team first",
|
||||
"allTeamModels": "All Team Models",
|
||||
"maxBudgetLabel": "Max Budget (USD)",
|
||||
"advancedSettingsTitle": "Advanced Settings",
|
||||
"blockProjectLabel": "Block Project",
|
||||
"blockProjectWarning": "All API requests using keys under this project will be rejected.",
|
||||
"guardrailsLabel": "Guardrails",
|
||||
"guardrailsHelp": "Select existing guardrails or enter new ones",
|
||||
"guardrailsPlaceholder": "Select or enter guardrails",
|
||||
"modelLimitsTitle": "Model-Specific Limits",
|
||||
"missingModel": "Missing model",
|
||||
"duplicateModel": "Duplicate model",
|
||||
"modelNamePlaceholder": "Model name (e.g. gpt-4)",
|
||||
"tpmPlaceholder": "TPM Limit",
|
||||
"rpmPlaceholder": "RPM Limit",
|
||||
"addModelLimit": "Add Model Limit",
|
||||
"metadataTitle": "Metadata",
|
||||
"missingKey": "Missing key",
|
||||
"duplicateKey": "Duplicate key",
|
||||
"keyPlaceholder": "Key",
|
||||
"missingValue": "Missing value",
|
||||
"valuePlaceholder": "Value",
|
||||
"addKeyValue": "Add Key-Value Pair"
|
||||
},
|
||||
"projectsPage": {
|
||||
"title": "Projects",
|
||||
"subtitle": "Manage projects within your teams",
|
||||
"createBtn": "Create Project",
|
||||
"searchPlaceholder": "Search projects by name, ID, description, or team...",
|
||||
"totalProjects": "{{count}} projects",
|
||||
"colTeam": "Team",
|
||||
"colModels": "Models",
|
||||
"noModels": "No models",
|
||||
"statusBlocked": "Blocked"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6690,5 +6690,99 @@
|
|||
"successCount_other": "成功将 {{count}} 个模型组设为公开!",
|
||||
"failedToMakePublic": "将模型组设为公开失败,请重试。"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"projectDetailsPage": {
|
||||
"notFound": "项目未找到",
|
||||
"statusBlocked": "已封锁",
|
||||
"editProject": "编辑项目",
|
||||
"detailsTitle": "项目详情",
|
||||
"by": "由",
|
||||
"budgetTitle": "预算",
|
||||
"budgetOf": "共 ${{budget}} 预算",
|
||||
"noBudgetLimit": "无预算限制",
|
||||
"utilized": "已使用 {{percent}}%",
|
||||
"spendByModel": "按模型花费",
|
||||
"noModelSpend": "暂无模型花费记录",
|
||||
"keysTitle": "密钥",
|
||||
"noKeysToDisplay": "暂无密钥可显示",
|
||||
"teamTitle": "团队",
|
||||
"modelsLabel": "模型",
|
||||
"allModels": "所有模型",
|
||||
"spendLabel": "花费",
|
||||
"unlimited": "无限制",
|
||||
"membersLabel": "成员",
|
||||
"noTeamAssigned": "未分配团队"
|
||||
},
|
||||
"projectKeysSection": {
|
||||
"title": "密钥",
|
||||
"filterPlaceholder": "按密钥名称筛选...",
|
||||
"totalKeys": "共 {{count}} 个密钥"
|
||||
},
|
||||
"projectKeysTable": {
|
||||
"colKeyName": "密钥名称",
|
||||
"colOwner": "所有者",
|
||||
"colLastActive": "最后活跃",
|
||||
"noKeysFound": "未找到密钥"
|
||||
},
|
||||
"createProjectModal": {
|
||||
"title": "新建项目",
|
||||
"createBtn": "创建项目",
|
||||
"createSuccess": "项目创建成功",
|
||||
"createFailed": "创建项目失败"
|
||||
},
|
||||
"editProjectModal": {
|
||||
"title": "编辑项目",
|
||||
"saveBtn": "保存更改",
|
||||
"updateSuccess": "项目更新成功",
|
||||
"updateFailed": "更新项目失败"
|
||||
},
|
||||
"projectBaseForm": {
|
||||
"basicInfoTitle": "基本信息",
|
||||
"projectNameLabel": "项目名称",
|
||||
"projectNameRequired": "请输入项目名称",
|
||||
"projectNamePlaceholder": "例如:客服机器人",
|
||||
"teamLabel": "团队",
|
||||
"teamRequired": "请选择团队",
|
||||
"teamPlaceholder": "搜索或选择团队",
|
||||
"descriptionPlaceholder": "描述该项目的用途",
|
||||
"allowedModelsLabel": "允许的模型(限定于所选团队的模型)",
|
||||
"selectTeamFirst": "请先选择团队以查看可用模型",
|
||||
"selectModels": "选择模型",
|
||||
"selectTeamFirstPlaceholder": "请先选择团队",
|
||||
"allTeamModels": "团队所有模型",
|
||||
"maxBudgetLabel": "最大预算(美元)",
|
||||
"advancedSettingsTitle": "高级设置",
|
||||
"blockProjectLabel": "封锁项目",
|
||||
"blockProjectWarning": "该项目下所有密钥的 API 请求将被拒绝。",
|
||||
"guardrailsLabel": "护栏",
|
||||
"guardrailsHelp": "选择已有护栏或输入新护栏",
|
||||
"guardrailsPlaceholder": "选择或输入护栏",
|
||||
"modelLimitsTitle": "模型专属限制",
|
||||
"missingModel": "缺少模型名称",
|
||||
"duplicateModel": "模型重复",
|
||||
"modelNamePlaceholder": "模型名称(例如 gpt-4)",
|
||||
"tpmPlaceholder": "TPM 限制",
|
||||
"rpmPlaceholder": "RPM 限制",
|
||||
"addModelLimit": "添加模型限制",
|
||||
"metadataTitle": "元数据",
|
||||
"missingKey": "缺少键名",
|
||||
"duplicateKey": "键名重复",
|
||||
"keyPlaceholder": "键",
|
||||
"missingValue": "缺少值",
|
||||
"valuePlaceholder": "值",
|
||||
"addKeyValue": "添加键值对"
|
||||
},
|
||||
"projectsPage": {
|
||||
"title": "项目",
|
||||
"subtitle": "管理团队内的项目",
|
||||
"createBtn": "创建项目",
|
||||
"searchPlaceholder": "按名称、ID、描述或团队搜索项目...",
|
||||
"totalProjects": "共 {{count}} 个项目",
|
||||
"colTeam": "团队",
|
||||
"colModels": "模型",
|
||||
"noModels": "无模型",
|
||||
"statusBlocked": "已封锁"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue