feat: add internationalization support with next-intl

This commit is contained in:
beilunyang
2025-10-13 00:57:32 +08:00
parent 0fcc4b9e85
commit d175017b51
46 changed files with 1436 additions and 432 deletions

View File

@@ -1,6 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
@@ -19,7 +20,10 @@ interface CreateDialogProps {
}
export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
const { config } = useConfig()
const { config } = useConfig()
const t = useTranslations("emails.create")
const tList = useTranslations("emails.list")
const tCommon = useTranslations("common.actions")
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [emailName, setEmailName] = useState("")
@@ -37,8 +41,8 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
const createEmail = async () => {
if (!emailName.trim()) {
toast({
title: "错误",
description: "请输入邮箱名",
title: tList("error"),
description: t("namePlaceholder"),
variant: "destructive"
})
return
@@ -59,7 +63,7 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
if (!response.ok) {
const data = await response.json()
toast({
title: "错误",
title: tList("error"),
description: (data as { error: string }).error,
variant: "destructive"
})
@@ -67,16 +71,16 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
}
toast({
title: "成功",
description: "已创建新的临时邮箱"
title: tList("success"),
description: t("success")
})
onEmailCreated()
setOpen(false)
setEmailName("")
} catch {
toast({
title: "错误",
description: "创建邮箱失败",
title: tList("error"),
description: t("failed"),
variant: "destructive"
})
} finally {
@@ -95,19 +99,19 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="w-4 h-4" />
{t("title")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Input
value={emailName}
onChange={(e) => setEmailName(e.target.value)}
placeholder="输入邮箱名"
placeholder={t("namePlaceholder")}
className="flex-1"
/>
{(config?.emailDomainsArray?.length ?? 0) > 1 && (
@@ -133,25 +137,28 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
</div>
<div className="flex items-center gap-4">
<Label className="shrink-0 text-muted-foreground"></Label>
<Label className="shrink-0 text-muted-foreground">{t("expiryTime")}</Label>
<RadioGroup
value={expiryTime}
onValueChange={setExpiryTime}
className="flex gap-6"
>
{EXPIRY_OPTIONS.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value.toString()} id={option.value.toString()} />
<Label htmlFor={option.value.toString()} className="cursor-pointer text-sm">
{option.label}
</Label>
</div>
))}
{EXPIRY_OPTIONS.map((option, index) => {
const labels = [t("oneHour"), t("oneDay"), t("threeDays"), t("permanent")]
return (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value.toString()} id={option.value.toString()} />
<Label htmlFor={option.value.toString()} className="cursor-pointer text-sm">
{labels[index]}
</Label>
</div>
)
})}
</RadioGroup>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="shrink-0">:</span>
<span className="shrink-0">{t("domain")}:</span>
{emailName ? (
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{`${emailName}@${currentDomain}`}</span>
@@ -169,10 +176,10 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
{tCommon("cancel")}
</Button>
<Button onClick={createEmail} disabled={loading}>
{loading ? "创建中..." : "创建"}
{loading ? t("creating") : t("create")}
</Button>
</div>
</DialogContent>

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"
import { useSession } from "next-auth/react"
import { useTranslations } from "next-intl"
import { CreateDialog } from "./create-dialog"
import { Mail, RefreshCw, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
@@ -45,6 +46,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
const { data: session } = useSession()
const { config } = useConfig()
const { role } = useUserRole()
const t = useTranslations("emails.list")
const tCommon = useTranslations("common.actions")
const [emails, setEmails] = useState<Email[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -125,7 +128,7 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
if (!response.ok) {
const data = await response.json()
toast({
title: "错误",
title: t("error"),
description: (data as { error: string }).error,
variant: "destructive"
})
@@ -136,8 +139,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
setTotal(prev => prev - 1)
toast({
title: "成功",
description: "邮箱已删除"
title: t("success"),
description: t("deleteSuccess")
})
if (selectedEmailId === email.id) {
@@ -145,8 +148,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
}
} catch {
toast({
title: "错误",
description: "删除邮箱失败",
title: t("error"),
description: t("deleteFailed"),
variant: "destructive"
})
} finally {
@@ -172,9 +175,9 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
</Button>
<span className="text-xs text-gray-500">
{role === ROLES.EMPEROR ? (
`${total}/∞ 个邮箱`
t("emailCountUnlimited", { count: total })
) : (
`${total}/${config?.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱`
t("emailCount", { count: total, max: config?.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS })
)}
</span>
</div>
@@ -183,7 +186,7 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
<div className="flex-1 overflow-auto p-2" onScroll={handleScroll}>
{loading ? (
<div className="text-center text-sm text-gray-500">...</div>
<div className="text-center text-sm text-gray-500">{t("loading")}</div>
) : emails.length > 0 ? (
<div className="space-y-1">
{emails.map(email => (
@@ -200,9 +203,9 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
<div className="font-medium truncate">{email.address}</div>
<div className="text-xs text-gray-500">
{new Date(email.expiresAt).getFullYear() === 9999 ? (
"永久有效"
t("permanent")
) : (
`过期时间: ${new Date(email.expiresAt).toLocaleString()}`
`${t("expiresAt")}: ${new Date(email.expiresAt).toLocaleString()}`
)}
</div>
</div>
@@ -221,13 +224,13 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
))}
{loadingMore && (
<div className="text-center text-sm text-gray-500 py-2">
...
{t("loadingMore")}
</div>
)}
</div>
) : (
<div className="text-center text-sm text-gray-500">
{t("noEmails")}
</div>
)}
</div>
@@ -236,18 +239,18 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
<AlertDialog open={!!emailToDelete} onOpenChange={() => setEmailToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{emailToDelete?.address}
{t("deleteDescription", { email: emailToDelete?.address })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => emailToDelete && handleDelete(emailToDelete)}
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Send, Inbox } from "lucide-react"
import { Tabs, SlidingTabsList, SlidingTabsTrigger, TabsContent } from "@/components/ui/tabs"
import { MessageList } from "./message-list"
@@ -17,6 +18,7 @@ interface MessageListContainerProps {
}
export function MessageListContainer({ email, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListContainerProps) {
const t = useTranslations("emails.messages")
const [activeTab, setActiveTab] = useState<'received' | 'sent'>('received')
const { canSend: canSendEmails } = useSendPermission()
@@ -33,11 +35,11 @@ export function MessageListContainer({ email, onMessageSelect, selectedMessageId
<SlidingTabsList>
<SlidingTabsTrigger value="received">
<Inbox className="h-4 w-4" />
{t("received")}
</SlidingTabsTrigger>
<SlidingTabsTrigger value="sent">
<Send className="h-4 w-4" />
{t("sent")}
</SlidingTabsTrigger>
</SlidingTabsList>
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useTranslations } from "next-intl"
import {Mail, Calendar, RefreshCw, Trash2} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -47,6 +48,9 @@ interface MessageResponse {
}
export function MessageList({ email, messageType, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListProps) {
const t = useTranslations("emails.messages")
const tList = useTranslations("emails.list")
const tCommon = useTranslations("common.actions")
const [messages, setMessages] = useState<Message[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -149,7 +153,7 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
if (!response.ok) {
const data = await response.json()
toast({
title: "错误",
title: tList("error"),
description: (data as { error: string }).error,
variant: "destructive"
})
@@ -160,8 +164,8 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
setTotal(prev => prev - 1)
toast({
title: "成功",
description: "邮件已删除"
title: tList("success"),
description: tList("deleteSuccess")
})
if (selectedMessageId === message.id) {
@@ -169,8 +173,8 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
}
} catch {
toast({
title: "错误",
description: "删除邮件失败",
title: tList("error"),
description: tList("deleteFailed"),
variant: "destructive"
})
} finally {
@@ -215,13 +219,13 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
<RefreshCw className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500">
{total > 0 ? `${total} 封邮件` : "暂无邮件"}
{total > 0 ? `${total} ${t("noMessages")}` : t("noMessages")}
</span>
</div>
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
{loading ? (
<div className="p-4 text-center text-sm text-gray-500">...</div>
<div className="p-4 text-center text-sm text-gray-500">{t("loading")}</div>
) : messages.length > 0 ? (
<div className="divide-y divide-primary/10">
{messages.map(message => (
@@ -263,13 +267,13 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
))}
{loadingMore && (
<div className="text-center text-sm text-gray-500 py-2">
...
{t("loadingMore")}
</div>
)}
</div>
) : (
<div className="p-4 text-center text-sm text-gray-500">
{messageType === 'sent' ? '暂无发送的邮件' : '暂无收到的邮件'}
{t("noMessages")}
</div>
)}
</div>
@@ -277,18 +281,18 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
<AlertDialog open={!!messageToDelete} onOpenChange={() => setMessageToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{tList("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{messageToDelete?.subject}
{tList("deleteDescription", { email: messageToDelete?.subject })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => messageToDelete && handleDelete(messageToDelete)}
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useTranslations } from "next-intl"
import { Loader2 } from "lucide-react"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
@@ -28,6 +29,8 @@ interface MessageViewProps {
type ViewMode = "html" | "text"
export function MessageView({ emailId, messageId, messageType = 'received' }: MessageViewProps) {
const t = useTranslations("emails.messageView")
const tList = useTranslations("emails.list")
const [message, setMessage] = useState<Message | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -48,10 +51,10 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
if (!response.ok) {
const errorData = await response.json()
const errorMessage = (errorData as { error?: string }).error || '获取邮件详情失败'
const errorMessage = (errorData as { error?: string }).error || t("loadError")
setError(errorMessage)
toast({
title: "错误",
title: tList("error"),
description: errorMessage,
variant: "destructive"
})
@@ -64,10 +67,10 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
setViewMode("text")
}
} catch (error) {
const errorMessage = "网络错误,请稍后重试"
const errorMessage = t("networkError")
setError(errorMessage)
toast({
title: "错误",
title: tList("error"),
description: errorMessage,
variant: "destructive"
})
@@ -78,7 +81,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
}
fetchMessage()
}, [emailId, messageId, messageType, toast])
}, [emailId, messageId, messageType, toast, t, tList])
const updateIframeContent = () => {
if (viewMode === "html" && message?.html && iframeRef.current) {
@@ -182,7 +185,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
return (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
<span className="ml-2 text-sm text-gray-500">...</span>
<span className="ml-2 text-sm text-gray-500">{t("loading")}</span>
</div>
)
}
@@ -195,7 +198,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
onClick={() => window.location.reload()}
className="text-xs text-primary hover:underline"
>
{t("retry")}
</button>
</div>
)
@@ -209,12 +212,12 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
<h3 className="text-base font-bold">{message.subject}</h3>
<div className="text-xs text-gray-500 space-y-1">
{message.from_address && (
<p>{message.from_address}</p>
<p>{t("from")}: {message.from_address}</p>
)}
{message.to_address && (
<p>{message.to_address}</p>
<p>{t("to")}: {message.to_address}</p>
)}
<p>{new Date(message.sent_at || message.received_at || 0).toLocaleString()}</p>
<p>{t("time")}: {new Date(message.sent_at || message.received_at || 0).toLocaleString()}</p>
</div>
</div>
@@ -231,7 +234,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
htmlFor="html"
className="text-xs cursor-pointer"
>
HTML
{t("htmlFormat")}
</Label>
</div>
<div className="flex items-center space-x-2">
@@ -240,7 +243,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
htmlFor="text"
className="text-xs cursor-pointer"
>
{t("textFormat")}
</Label>
</div>
</RadioGroup>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
@@ -21,6 +22,9 @@ interface SendDialogProps {
}
export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogProps) {
const t = useTranslations("emails.send")
const tList = useTranslations("emails.list")
const tCommon = useTranslations("common.actions")
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [to, setTo] = useState("")
@@ -31,8 +35,8 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
const handleSend = async () => {
if (!to.trim() || !subject.trim() || !content.trim()) {
toast({
title: "错误",
description: "收件人、主题和内容都是必填项",
title: tList("error"),
description: t("toPlaceholder") + ", " + t("subjectPlaceholder") + ", " + t("contentPlaceholder"),
variant: "destructive"
})
return
@@ -49,7 +53,7 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
if (!response.ok) {
const data = await response.json()
toast({
title: "错误",
title: tList("error"),
description: (data as { error: string }).error,
variant: "destructive"
})
@@ -57,8 +61,8 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
}
toast({
title: "成功",
description: "邮件已发送"
title: tList("success"),
description: t("success")
})
setOpen(false)
setTo("")
@@ -69,8 +73,8 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
} catch {
toast({
title: "错误",
description: "发送邮件失败",
title: tList("error"),
description: t("failed"),
variant: "destructive"
})
} finally {
@@ -90,46 +94,46 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
className="h-8 gap-2 hover:bg-primary/10 hover:text-primary transition-colors"
>
<Send className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="hidden sm:inline">{t("title")}</span>
</Button>
</TooltipTrigger>
</DialogTrigger>
<TooltipContent className="sm:hidden">
<p>使</p>
<p>{t("title")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-sm text-muted-foreground">
: {fromAddress}
{t("from")}: {fromAddress}
</div>
<Input
value={to}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTo(e.target.value)}
placeholder="收件人邮箱地址"
placeholder={t("toPlaceholder")}
/>
<Input
value={subject}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSubject(e.target.value)}
placeholder="邮件主题"
placeholder={t("subjectPlaceholder")}
/>
<Textarea
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value)}
placeholder="邮件内容"
placeholder={t("contentPlaceholder")}
rows={6}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSend} disabled={loading}>
{loading ? "发送中..." : "发送"}
{loading ? t("sending") : t("send")}
</Button>
</div>
</DialogContent>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { EmailList } from "./email-list"
import { MessageListContainer } from "./message-list-container"
import { MessageView } from "./message-view"
@@ -16,6 +17,7 @@ interface Email {
}
export function ThreeColumnLayout() {
const t = useTranslations("emails.layout")
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null)
const [selectedMessageType, setSelectedMessageType] = useState<'received' | 'sent'>('received')
@@ -55,7 +57,7 @@ export function ThreeColumnLayout() {
<div className="hidden lg:grid grid-cols-12 gap-4 h-full min-h-0">
<div className={cn("col-span-3", columnClass)}>
<div className={headerClass}>
<h2 className={titleClass}></h2>
<h2 className={titleClass}>{t("myEmails")}</h2>
</div>
<div className="flex-1 overflow-auto">
<EmailList
@@ -88,7 +90,7 @@ export function ThreeColumnLayout() {
)}
</div>
) : (
"选择邮箱查看消息"
t("selectEmail")
)}
</h2>
</div>
@@ -107,7 +109,7 @@ export function ThreeColumnLayout() {
<div className={cn("col-span-5", columnClass)}>
<div className={headerClass}>
<h2 className={titleClass}>
{selectedMessageId ? "邮件内容" : "选择邮件查看详情"}
{selectedMessageId ? t("messageContent") : t("selectMessage")}
</h2>
</div>
{selectedEmail && selectedMessageId && (
@@ -129,7 +131,7 @@ export function ThreeColumnLayout() {
{mobileView === "list" && (
<>
<div className={headerClass}>
<h2 className={titleClass}></h2>
<h2 className={titleClass}>{t("myEmails")}</h2>
</div>
<div className="flex-1 overflow-auto">
<EmailList
@@ -151,7 +153,7 @@ export function ThreeColumnLayout() {
}}
className="text-sm text-primary shrink-0"
>
{t("backToEmailList")}
</button>
<div className="flex-1 flex justify-between items-center gap-2 min-w-0">
<div className="flex items-center gap-2">
@@ -187,9 +189,9 @@ export function ThreeColumnLayout() {
onClick={() => setSelectedMessageId(null)}
className="text-sm text-primary"
>
{t("backToMessageList")}
</button>
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium">{t("messageContent")}</span>
</div>
<div className="flex-1 overflow-auto">
<MessageView