feat(i18n): implement internationalization framework and update UI components for language support

This commit is contained in:
HouYunFei
2026-08-05 14:08:03 +08:00
parent 9bccd0ff1a
commit 0f6809251a
116 changed files with 3269 additions and 1870 deletions
+5 -3
View File
@@ -1,6 +1,7 @@
import { Copy, FileText } from "lucide-react";
import type { ReactNode } from "react";
import { Button, Card, Tag } from "antd";
import { useTranslation } from "react-i18next";
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
@@ -8,7 +9,7 @@ export function PromptCard({
item,
onOpen,
onCopy,
actionLabel = "复制",
actionLabel,
actionIcon = <Copy className="size-3.5" />,
actionType = "text",
extraAction,
@@ -21,6 +22,7 @@ export function PromptCard({
actionType?: "text" | "primary";
extraAction?: ReactNode;
}) {
const { i18n, t } = useTranslation();
return (
<Card
hoverable
@@ -36,7 +38,7 @@ export function PromptCard({
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt, i18n.resolvedLanguage)}</span>
</div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.description || item.prompt}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
@@ -50,7 +52,7 @@ export function PromptCard({
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>
{actionLabel}
{actionLabel || t("common.copy")}
</Button>
{extraAction}
</div>
@@ -1,6 +1,7 @@
import { Check, Search } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react";
import { App, Empty, Input, Modal, Spin, Tag } from "antd";
import { useTranslation } from "react-i18next";
import { ALL_PROMPTS_OPTION } from "@/services/api/prompts";
import { cn } from "@/lib/utils";
@@ -9,6 +10,7 @@ import { usePromptList } from "./use-prompt-list";
export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (prompt: string) => void }) {
const { message } = App.useApp();
const { t } = useTranslation();
const [keyword, setKeyword] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
@@ -23,8 +25,8 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
};
useEffect(() => {
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
}, [message, query.error, query.isError]);
if (query.isError) message.error(query.error instanceof Error ? query.error.message : t("prompts.loadFailed"));
}, [message, query.error, query.isError, t]);
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
@@ -32,30 +34,30 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
};
return (
<Modal title="提示词库" open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered>
<Modal title={t("prompts.library")} open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered>
<div data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}>
<div className="mx-auto max-w-2xl">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="按标题查询" />
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder={t("prompts.searchTitle")} />
</div>
<div className="mt-5 grid gap-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">分类</div>
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">{t("prompts.category")}</div>
<div className="flex flex-wrap gap-2">
{promptCategories.map((category) => (
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
{category}
{category === ALL_PROMPTS_OPTION ? t("common.all") : category}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">{t("prompts.tags")}</div>
<div className="flex flex-wrap gap-2">
{promptTags.map((tag) => {
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
return (
<Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>
{tag}
{tag === ALL_PROMPTS_OPTION ? t("common.all") : tag}
</Tag.CheckableTag>
);
})}
@@ -70,10 +72,10 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
) : null}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" />
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel={t("prompts.use")} actionIcon={<Check className="size-3.5" />} actionType="primary" />
))}
</div>
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null}
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("prompts.empty")} className="py-8" /> : null}
{query.isFetchingNextPage ? (
<div className="py-4 text-center">
<Spin size="small" />