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

@@ -2,6 +2,7 @@
import { useState } from "react"
import { signIn } from "next-auth/react"
import { useTranslations } from "next-intl"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -34,25 +35,26 @@ export function LoginForm() {
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<FormErrors>({})
const { toast } = useToast()
const t = useTranslations("auth.loginForm")
const validateLoginForm = () => {
const newErrors: FormErrors = {}
if (!username) newErrors.username = "请输入用户名"
if (!password) newErrors.password = "请输入密码"
if (username.includes('@')) newErrors.username = "用户名不能包含 @ 符号"
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
if (!username) newErrors.username = t("errors.usernameRequired")
if (!password) newErrors.password = t("errors.passwordRequired")
if (username.includes('@')) newErrors.username = t("errors.usernameInvalid")
if (password && password.length < 8) newErrors.password = t("errors.passwordTooShort")
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const validateRegisterForm = () => {
const newErrors: FormErrors = {}
if (!username) newErrors.username = "请输入用户名"
if (!password) newErrors.password = "请输入密码"
if (username.includes('@')) newErrors.username = "用户名不能包含 @ 符号"
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
if (!confirmPassword) newErrors.confirmPassword = "请确认密码"
if (password !== confirmPassword) newErrors.confirmPassword = "两次输入的密码不一致"
if (!username) newErrors.username = t("errors.usernameRequired")
if (!password) newErrors.password = t("errors.passwordRequired")
if (username.includes('@')) newErrors.username = t("errors.usernameInvalid")
if (password && password.length < 8) newErrors.password = t("errors.passwordTooShort")
if (!confirmPassword) newErrors.confirmPassword = t("errors.confirmPasswordRequired")
if (password !== confirmPassword) newErrors.confirmPassword = t("errors.passwordMismatch")
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
@@ -70,8 +72,8 @@ export function LoginForm() {
if (result?.error) {
toast({
title: "登录失败",
description: "用户名或密码错误",
title: t("toast.loginFailed"),
description: t("toast.loginFailedDesc"),
variant: "destructive",
})
setLoading(false)
@@ -81,8 +83,8 @@ export function LoginForm() {
window.location.href = "/"
} catch (error) {
toast({
title: "登录失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("toast.loginFailed"),
description: error instanceof Error ? error.message : t("toast.registerFailedDesc"),
variant: "destructive",
})
setLoading(false)
@@ -104,8 +106,8 @@ export function LoginForm() {
if (!response.ok) {
toast({
title: "注册失败",
description: data.error || "请稍后重试",
title: t("toast.registerFailed"),
description: data.error || t("toast.registerFailedDesc"),
variant: "destructive",
})
setLoading(false)
@@ -121,8 +123,8 @@ export function LoginForm() {
if (result?.error) {
toast({
title: "登录失败",
description: "自动登录失败,请手动登录",
title: t("toast.loginFailed"),
description: t("toast.autoLoginFailed"),
variant: "destructive",
})
setLoading(false)
@@ -132,8 +134,8 @@ export function LoginForm() {
window.location.href = "/"
} catch (error) {
toast({
title: "注册失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("toast.registerFailed"),
description: error instanceof Error ? error.message : t("toast.registerFailedDesc"),
variant: "destructive",
})
setLoading(false)
@@ -155,17 +157,17 @@ export function LoginForm() {
<Card className="w-[95%] max-w-lg border-2 border-primary/20">
<CardHeader className="space-y-2">
<CardTitle className="text-2xl text-center bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
使 MoeMail
{t("title")}
</CardTitle>
<CardDescription className="text-center">
()
{t("subtitle")}
</CardDescription>
</CardHeader>
<CardContent className="px-6">
<Tabs defaultValue="login" className="w-full" onValueChange={clearForm}>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="login"></TabsTrigger>
<TabsTrigger value="register"></TabsTrigger>
<TabsTrigger value="login">{t("tabs.login")}</TabsTrigger>
<TabsTrigger value="register">{t("tabs.register")}</TabsTrigger>
</TabsList>
<div className="min-h-[220px]">
<TabsContent value="login" className="space-y-4 mt-0">
@@ -180,7 +182,7 @@ export function LoginForm() {
"h-9 pl-9 pr-3",
errors.username && "border-destructive focus-visible:ring-destructive"
)}
placeholder="用户名"
placeholder={t("fields.username")}
value={username}
onChange={(e) => {
setUsername(e.target.value)
@@ -204,7 +206,7 @@ export function LoginForm() {
errors.password && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="密码"
placeholder={t("fields.password")}
value={password}
onChange={(e) => {
setPassword(e.target.value)
@@ -226,7 +228,7 @@ export function LoginForm() {
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("actions.login")}
</Button>
<div className="relative">
@@ -235,7 +237,7 @@ export function LoginForm() {
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
{t("actions.or")}
</span>
</div>
</div>
@@ -246,7 +248,7 @@ export function LoginForm() {
onClick={handleGithubLogin}
>
<Github className="mr-2 h-4 w-4" />
使 GitHub
{t("actions.githubLogin")}
</Button>
</div>
</TabsContent>
@@ -262,7 +264,7 @@ export function LoginForm() {
"h-9 pl-9 pr-3",
errors.username && "border-destructive focus-visible:ring-destructive"
)}
placeholder="用户名"
placeholder={t("fields.username")}
value={username}
onChange={(e) => {
setUsername(e.target.value)
@@ -286,7 +288,7 @@ export function LoginForm() {
errors.password && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="密码"
placeholder={t("fields.password")}
value={password}
onChange={(e) => {
setPassword(e.target.value)
@@ -310,7 +312,7 @@ export function LoginForm() {
errors.confirmPassword && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="确认密码"
placeholder={t("fields.confirmPassword")}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value)
@@ -332,7 +334,7 @@ export function LoginForm() {
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("actions.register")}
</Button>
</div>
</TabsContent>

View File

@@ -5,6 +5,7 @@ import Image from "next/image"
import { signOut, useSession } from "next-auth/react"
import { LogIn } from "lucide-react"
import { useRouter } from 'next/navigation'
import { useTranslations, useLocale } from "next-intl"
import Link from "next/link"
import { cn } from "@/lib/utils"
@@ -14,7 +15,9 @@ interface SignButtonProps {
export function SignButton({ size = "default" }: SignButtonProps) {
const router = useRouter()
const locale = useLocale()
const { data: session, status } = useSession()
const t = useTranslations("auth.signButton")
const loading = status === "loading"
if (loading) {
@@ -23,9 +26,9 @@ export function SignButton({ size = "default" }: SignButtonProps) {
if (!session?.user) {
return (
<Button onClick={() => router.push('/login')} className={cn("gap-2", size === "lg" ? "px-8" : "")} size={size}>
<Button onClick={() => router.push(`/${locale}/login`)} className={cn("gap-2", size === "lg" ? "px-8" : "")} size={size}>
<LogIn className={size === "lg" ? "w-5 h-5" : "w-4 h-4"} />
/
{t("login")}
</Button>
)
}
@@ -33,13 +36,13 @@ export function SignButton({ size = "default" }: SignButtonProps) {
return (
<div className="flex items-center gap-4">
<Link
href="/profile"
href={`/${locale}/profile`}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
{session.user.image && (
<Image
src={session.user.image}
alt={session.user.name || "用户头像"}
alt={session.user.name || t("userAvatar")}
width={24}
height={24}
className="rounded-full"
@@ -47,8 +50,8 @@ export function SignButton({ size = "default" }: SignButtonProps) {
)}
<span className="text-sm">{session.user.name}</span>
</Link>
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline" className={cn("flex-shrink-0", size === "lg" ? "px-8" : "")} size={size}>
<Button onClick={() => signOut({ callbackUrl: `/${locale}` })} variant="outline" className={cn("flex-shrink-0", size === "lg" ? "px-8" : "")} size={size}>
{t("logout")}
</Button>
</div>
)

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

View File

@@ -1,5 +1,6 @@
"use client"
import { useTranslations } from "next-intl"
import { Github } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
@@ -10,6 +11,8 @@ import {
} from "@/components/ui/tooltip"
export function FloatMenu() {
const t = useTranslations("common")
return (
<div className="fixed bottom-6 right-6">
<TooltipProvider>
@@ -24,12 +27,12 @@ export function FloatMenu() {
<Github
className="w-4 h-4 transition-all duration-300 text-primary group-hover:scale-110"
/>
<span className="sr-only"></span>
<span className="sr-only">{t("github")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm">
<p></p>
<p>{t("github")}</p>
</div>
</TooltipContent>
</Tooltip>

View File

@@ -3,6 +3,7 @@
import { Button } from "@/components/ui/button"
import { Mail } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations, useLocale } from "next-intl"
import { SignButton } from "../auth/sign-button"
interface ActionButtonProps {
@@ -11,16 +12,18 @@ interface ActionButtonProps {
export function ActionButton({ isLoggedIn }: ActionButtonProps) {
const router = useRouter()
const locale = useLocale()
const t = useTranslations("home")
if (isLoggedIn) {
return (
<Button
size="lg"
onClick={() => router.push("/moe")}
onClick={() => router.push(`/${locale}/moe`)}
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
>
<Mail className="w-5 h-5" />
{t("actions.enterMailbox")}
</Button>
)
}

View File

@@ -1,5 +1,6 @@
import { SignButton } from "@/components/auth/sign-button"
import { ThemeToggle } from "@/components/theme/theme-toggle"
import { LanguageSwitcher } from "@/components/layout/language-switcher"
import { Logo } from "@/components/ui/logo"
export function Header() {
@@ -9,6 +10,7 @@ export function Header() {
<div className="h-full flex items-center justify-between">
<Logo />
<div className="flex items-center gap-4">
<LanguageSwitcher />
<ThemeToggle />
<SignButton />
</div>

View File

@@ -0,0 +1,62 @@
"use client"
import { useLocale, useTranslations } from "next-intl"
import { usePathname, useRouter } from "next/navigation"
import { i18n } from "@/i18n/config"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Languages } from "lucide-react"
export function LanguageSwitcher() {
const t = useTranslations("common")
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const switchLocale = (newLocale: string) => {
if (newLocale === locale) return
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
const segments = pathname.split("/")
if (i18n.locales.includes(segments[1] as any)) {
segments[1] = newLocale
} else {
segments.splice(1, 0, newLocale)
}
const newPath = segments.join("/")
router.push(newPath)
router.refresh()
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Languages className="h-5 w-5" />
<span className="sr-only">{t("lang.switch")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => switchLocale("en")}
className={locale === "en" ? "bg-accent" : ""}
>
{t("lang.en")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => switchLocale("zh-CN")}
className={locale === "zh-CN" ? "bg-accent" : ""}
>
{t("lang.zhCN")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -2,9 +2,13 @@
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { useTranslations, useLocale } from "next-intl"
import { useConfig } from "@/hooks/use-config"
export function NoPermissionDialog() {
const router = useRouter()
const locale = useLocale()
const t = useTranslations("emails.noPermission")
const { config } = useConfig()
return (
@@ -12,18 +16,18 @@ export function NoPermissionDialog() {
<div className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[90%] max-w-md">
<div className="bg-background border-2 border-primary/20 rounded-lg p-6 md:p-12 shadow-lg">
<div className="text-center space-y-4">
<h1 className="text-xl md:text-2xl font-bold"></h1>
<p className="text-sm md:text-base text-muted-foreground">访</p>
<h1 className="text-xl md:text-2xl font-bold">{t("title")}</h1>
<p className="text-sm md:text-base text-muted-foreground">{t("description")}</p>
{
config?.adminContact && (
<p className="text-sm md:text-base text-muted-foreground">{config.adminContact}</p>
<p className="text-sm md:text-base text-muted-foreground">{t("adminContact")}{config.adminContact}</p>
)
}
<Button
onClick={() => router.push("/")}
onClick={() => router.push(`/${locale}`)}
className="mt-4 w-full md:w-auto"
>
{t("backToHome")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Key, Plus, Loader2, Copy, Trash2, ChevronDown, ChevronUp } from "lucide-react"
@@ -32,6 +33,10 @@ type ApiKey = {
}
export function ApiKeyPanel() {
const t = useTranslations("profile.apiKey")
const tCommon = useTranslations("common.actions")
const tNoPermission = useTranslations("emails.noPermission")
const tMessages = useTranslations("emails.messages")
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
const [loading, setLoading] = useState(false)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
@@ -47,14 +52,14 @@ export function ApiKeyPanel() {
const fetchApiKeys = async () => {
try {
const res = await fetch("/api/api-keys")
if (!res.ok) throw new Error("获取 API Keys 失败")
if (!res.ok) throw new Error(t("createFailed"))
const data = await res.json() as { apiKeys: ApiKey[] }
setApiKeys(data.apiKeys)
} catch (error) {
console.error(error)
toast({
title: "获取失败",
description: "获取 API Keys 列表失败",
title: t("createFailed"),
description: t("createFailed"),
variant: "destructive"
})
} finally {
@@ -81,15 +86,15 @@ export function ApiKeyPanel() {
body: JSON.stringify({ name: newKeyName })
})
if (!res.ok) throw new Error("创建 API Key 失败")
if (!res.ok) throw new Error(t("createFailed"))
const data = await res.json() as { key: string }
setNewKey(data.key)
fetchApiKeys()
} catch (error) {
toast({
title: "创建失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("createFailed"),
description: error instanceof Error ? error.message : t("createFailed"),
variant: "destructive"
})
setCreateDialogOpen(false)
@@ -112,7 +117,7 @@ export function ApiKeyPanel() {
body: JSON.stringify({ enabled })
})
if (!res.ok) throw new Error("更新失败")
if (!res.ok) throw new Error(t("createFailed"))
setApiKeys(keys =>
keys.map(key =>
@@ -122,8 +127,8 @@ export function ApiKeyPanel() {
} catch (error) {
console.error(error)
toast({
title: "更新失败",
description: "更新 API Key 状态失败",
title: t("createFailed"),
description: t("createFailed"),
variant: "destructive"
})
}
@@ -135,18 +140,18 @@ export function ApiKeyPanel() {
method: "DELETE"
})
if (!res.ok) throw new Error("删除失败")
if (!res.ok) throw new Error(t("deleteFailed"))
setApiKeys(keys => keys.filter(key => key.id !== id))
toast({
title: "删除成功",
description: "API Key 已删除"
title: t("deleteSuccess"),
description: t("deleteSuccess")
})
} catch (error) {
console.error(error)
toast({
title: "删除失败",
description: "删除 API Key 失败",
title: t("deleteFailed"),
description: t("deleteFailed"),
variant: "destructive"
})
}
@@ -157,7 +162,7 @@ export function ApiKeyPanel() {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">API Keys</h2>
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
{
canManageApiKey && (
@@ -165,17 +170,17 @@ export function ApiKeyPanel() {
<DialogTrigger asChild>
<Button className="gap-2" onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4" />
API Key
{t("create")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{newKey ? "API Key 创建成功" : "创建新的 API Key"}
{newKey ? t("createSuccess") : t("create")}
</DialogTitle>
{newKey && (
<DialogDescription className="text-destructive">
{t("description")}
</DialogDescription>
)}
</DialogHeader>
@@ -183,18 +188,18 @@ export function ApiKeyPanel() {
{!newKey ? (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label></Label>
<Label>{t("name")}</Label>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="为你的 API Key 起个名字"
placeholder={t("namePlaceholder")}
/>
</div>
</div>
) : (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>API Key</Label>
<Label>{t("key")}</Label>
<div className="flex gap-2">
<Input
value={newKey}
@@ -220,7 +225,7 @@ export function ApiKeyPanel() {
onClick={handleDialogClose}
disabled={loading}
>
{newKey ? "完成" : "取消"}
{newKey ? tCommon("ok") : tCommon("cancel")}
</Button>
</DialogClose>
{!newKey && (
@@ -231,7 +236,7 @@ export function ApiKeyPanel() {
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"创建"
t("create")
)}
</Button>
)}
@@ -245,11 +250,11 @@ export function ApiKeyPanel() {
{
!canManageApiKey ? (
<div className="text-center text-muted-foreground py-8">
<p> API Key</p>
<p className="mt-2"></p>
<p>{tNoPermission("needPermission")}</p>
<p className="mt-2">{tNoPermission("contactAdmin")}</p>
{
config?.adminContact && (
<p className="mt-2">{config.adminContact}</p>
<p className="mt-2">{tNoPermission("adminContact")}: {config.adminContact}</p>
)
}
</div>
@@ -261,7 +266,7 @@ export function ApiKeyPanel() {
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
<div>
<p className="text-sm text-muted-foreground">...</p>
<p className="text-sm text-muted-foreground">{tMessages("loading")}</p>
</div>
</div>
) : apiKeys.length === 0 ? (
@@ -270,9 +275,9 @@ export function ApiKeyPanel() {
<Key className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="text-lg font-medium"> API Keys</h3>
<h3 className="text-lg font-medium">{t("noKeys")}</h3>
<p className="text-sm text-muted-foreground mt-1">
&quot;API Key&quot; API Key
{t("description")}
</p>
</div>
</div>
@@ -286,7 +291,7 @@ export function ApiKeyPanel() {
<div className="space-y-1">
<div className="font-medium">{key.name}</div>
<div className="text-sm text-muted-foreground">
{new Date(key.createdAt).toLocaleString()}
{t("createdAt")}: {new Date(key.createdAt).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2">
@@ -312,14 +317,14 @@ export function ApiKeyPanel() {
onClick={() => setShowExamples(!showExamples)}
>
{showExamples ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
使
{t("viewDocs")}
</button>
{showExamples && (
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("docs.getConfig")}</div>
<Button
variant="ghost"
size="icon"
@@ -339,7 +344,7 @@ export function ApiKeyPanel() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("docs.generateEmail")}</div>
<Button
variant="ghost"
size="icon"
@@ -371,7 +376,7 @@ export function ApiKeyPanel() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("docs.getEmails")}</div>
<Button
variant="ghost"
size="icon"
@@ -391,7 +396,7 @@ export function ApiKeyPanel() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("docs.getMessages")}</div>
<Button
variant="ghost"
size="icon"
@@ -411,7 +416,7 @@ export function ApiKeyPanel() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("docs.getMessage")}</div>
<Button
variant="ghost"
size="icon"
@@ -430,16 +435,16 @@ export function ApiKeyPanel() {
</div>
<div className="text-xs text-muted-foreground mt-4">
<p></p>
<p>{t("docs.notes")}</p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li> YOUR_API_KEY API Key</li>
<li>/api/config </li>
<li>emailId </li>
<li>messageId </li>
<li>expiryTime 3600000186400000160480000070</li>
<li>domain /api/config </li>
<li>cursor nextCursor</li>
<li> X-API-Key </li>
<li>{t("docs.note1")}</li>
<li>{t("docs.note2")}</li>
<li>{t("docs.note3")}</li>
<li>{t("docs.note4")}</li>
<li>{t("docs.note5")}</li>
<li>{t("docs.note6")}</li>
<li>{t("docs.note7")}</li>
<li>{t("docs.note8")}</li>
</ul>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import React, { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Zap, Eye, EyeOff } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
@@ -19,6 +20,10 @@ interface EmailServiceConfig {
}
export function EmailServiceConfig() {
const t = useTranslations("profile.emailService")
const tCard = useTranslations("profile.card")
const tSend = useTranslations("emails.send")
const tCommon = useTranslations("common.actions")
const [config, setConfig] = useState<EmailServiceConfig>({
enabled: false,
apiKey: "",
@@ -64,17 +69,17 @@ export function EmailServiceConfig() {
if (!res.ok) {
const error = await res.json() as { error: string }
throw new Error(error.error || "保存失败")
throw new Error(error.error || t("saveFailed"))
}
toast({
title: "保存成功",
description: "Resend 发件服务配置已更新",
title: t("saveSuccess"),
description: t("saveSuccess"),
})
} catch (error) {
toast({
title: "保存失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("saveFailed"),
description: error instanceof Error ? error.message : t("saveFailed"),
variant: "destructive",
})
} finally {
@@ -86,17 +91,17 @@ export function EmailServiceConfig() {
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-2 mb-6">
<Zap className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">Resend </h2>
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enabled" className="text-sm font-medium">
Resend
{t("enable")}
</Label>
<p className="text-xs text-muted-foreground">
使 Resend
{t("enableDescription")}
</p>
</div>
<Switch
@@ -112,7 +117,7 @@ export function EmailServiceConfig() {
<>
<div className="space-y-2">
<Label htmlFor="apiKey" className="text-sm font-medium">
Resend API Key
{t("apiKey")}
</Label>
<div className="relative">
<Input
@@ -120,7 +125,7 @@ export function EmailServiceConfig() {
type={showToken ? "text" : "password"}
value={config.apiKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig((prev: EmailServiceConfig) => ({ ...prev, apiKey: e.target.value }))}
placeholder="输入 Resend API Key"
placeholder={t("apiKeyPlaceholder")}
/>
<Button
type="button"
@@ -140,33 +145,33 @@ export function EmailServiceConfig() {
<div className="space-y-2">
<Label className="text-sm font-medium">
使
{t("roleLimits")}
</Label>
<div className="space-y-4">
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg text-sm">
<p className="font-semibold text-blue-900 mb-3 flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
{t("fixedRoleLimits")}
</p>
<div className="space-y-2 text-blue-800">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
<span><strong>Emperor ()</strong> - </span>
<span><strong>{tCard("roles.EMPEROR")}</strong> - {t("emperorLimit")}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full"></div>
<span><strong>Civilian ()</strong> - </span>
<span><strong>{tCard("roles.CIVILIAN")}</strong> - {t("civilianLimit")}</span>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-sm font-medium text-gray-900">{t("configRoleLabel")}</p>
</div>
{[
{ value: "duke", label: "Duke (公爵)", key: "duke" as const },
{ value: "knight", label: "Knight (骑士)", key: "knight" as const }
{ value: "duke", label: tCard("roles.DUKE"), key: "duke" as const },
{ value: "knight", label: tCard("roles.KNIGHT"), key: "knight" as const }
].map((role) => {
const isDisabled = config.roleLimits[role.key] === -1
const isEnabled = !isDisabled
@@ -208,13 +213,13 @@ export function EmailServiceConfig() {
{role.label}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{isEnabled ? '已启用发件权限' : '未启用发件权限'}
{isEnabled ? t("enabled") : t("disabled")}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="text-right">
<Label className="text-xs font-medium text-gray-600 block mb-1"></Label>
<Label className="text-xs font-medium text-gray-600 block mb-1">{t("dailyLimit")}</Label>
<div className="flex items-center space-x-2">
<Input
type="number"
@@ -233,9 +238,9 @@ export function EmailServiceConfig() {
placeholder="0"
disabled={isDisabled}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">/</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">{tSend("dailyLimitUnit")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">0 = </p>
<p className="text-xs text-muted-foreground mt-1">0 = {t("unlimited")}</p>
</div>
</div>
</div>
@@ -253,7 +258,7 @@ export function EmailServiceConfig() {
disabled={loading}
className="w-full"
>
{loading ? "保存中..." : "保存配置"}
{loading ? t("saving") : t("save")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import { User } from "next-auth"
import { useTranslations, useLocale } from "next-intl"
import Image from "next/image"
import { Button } from "@/components/ui/button"
import { signOut } from "next-auth/react"
@@ -19,13 +20,18 @@ interface ProfileCardProps {
}
const roleConfigs = {
emperor: { name: '皇帝', icon: Crown },
duke: { name: '公爵', icon: Gem },
knight: { name: '骑士', icon: Sword },
civilian: { name: '平民', icon: User2 },
emperor: { key: 'EMPEROR', icon: Crown },
duke: { key: 'DUKE', icon: Gem },
knight: { key: 'KNIGHT', icon: Sword },
civilian: { key: 'CIVILIAN', icon: User2 },
} as const
export function ProfileCard({ user }: ProfileCardProps) {
const t = useTranslations("profile.card")
const tAuth = useTranslations("auth.signButton")
const tWebhook = useTranslations("profile.webhook")
const tNav = useTranslations("common.nav")
const locale = useLocale()
const router = useRouter()
const { checkPermission } = useRolePermission()
const canManageWebhook = checkPermission(PERMISSIONS.MANAGE_WEBHOOK)
@@ -40,7 +46,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
{user.image && (
<Image
src={user.image}
alt={user.name || "用户头像"}
alt={user.name || tAuth("userAvatar")}
width={80}
height={80}
className="rounded-full ring-2 ring-primary/20"
@@ -55,14 +61,14 @@ export function ProfileCard({ user }: ProfileCardProps) {
// 先简单实现,后续再完善
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full flex-shrink-0">
<Github className="w-3 h-3" />
{tAuth("linked")}
</div>
)
}
</div>
<p className="text-sm text-muted-foreground truncate mt-1">
{
user.email ? user.email : `用户名: ${user.username}`
user.email ? user.email : `${t("name")}: ${user.username}`
}
</p>
{user.roles && (
@@ -70,14 +76,15 @@ export function ProfileCard({ user }: ProfileCardProps) {
{user.roles.map(({ name }) => {
const roleConfig = roleConfigs[name as keyof typeof roleConfigs]
const Icon = roleConfig.icon
const roleName = t(`roles.${roleConfig.key}` as any)
return (
<div
key={name}
className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"
title={roleConfig.name}
title={roleName}
>
<Icon className="w-3 h-3" />
{roleConfig.name}
{roleName}
</div>
)
})}
@@ -91,7 +98,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-2 mb-6">
<Settings className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">Webhook </h2>
<h2 className="text-lg font-semibold">{tWebhook("title")}</h2>
</div>
<WebhookConfig />
</div>
@@ -104,18 +111,18 @@ export function ProfileCard({ user }: ProfileCardProps) {
<div className="flex flex-col sm:flex-row gap-4 px-1">
<Button
onClick={() => router.push("/moe")}
onClick={() => router.push(`/${locale}/moe`)}
className="gap-2 flex-1"
>
<Mail className="w-4 h-4" />
{tNav("backToMailbox")}
</Button>
<Button
variant="outline"
onClick={() => signOut({ callbackUrl: "/" })}
onClick={() => signOut({ callbackUrl: `/${locale}` })}
className="flex-1"
>
退
{tAuth("logout")}
</Button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
"use client"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Gem, Sword, User2, Loader2 } from "lucide-react"
import { Input } from "@/components/ui/input"
@@ -20,19 +21,21 @@ const roleIcons = {
[ROLES.CIVILIAN]: User2,
} as const
const roleNames = {
[ROLES.DUKE]: "公爵",
[ROLES.KNIGHT]: "骑士",
[ROLES.CIVILIAN]: "平民",
} as const
type RoleWithoutEmperor = Exclude<Role, typeof ROLES.EMPEROR>
export function PromotePanel() {
const t = useTranslations("profile.promote")
const tCard = useTranslations("profile.card")
const [searchText, setSearchText] = useState("")
const [loading, setLoading] = useState(false)
const [targetRole, setTargetRole] = useState<RoleWithoutEmperor>(ROLES.KNIGHT)
const { toast } = useToast()
const roleNames = {
[ROLES.DUKE]: tCard("roles.DUKE"),
[ROLES.KNIGHT]: tCard("roles.KNIGHT"),
[ROLES.CIVILIAN]: tCard("roles.CIVILIAN"),
} as const
const handleAction = async () => {
if (!searchText) return
@@ -59,8 +62,8 @@ export function PromotePanel() {
if (!data.user) {
toast({
title: "未找到用户",
description: "请确认用户名或邮箱地址是否正确",
title: t("noUsers"),
description: t("searchPlaceholder"),
variant: "destructive"
})
return
@@ -68,8 +71,8 @@ export function PromotePanel() {
if (data.user.role === targetRole) {
toast({
title: `用户已是${roleNames[targetRole]}`,
description: "无需重复设置",
title: t("updateSuccess"),
description: t("updateSuccess"),
})
return
}
@@ -85,18 +88,18 @@ export function PromotePanel() {
if (!promoteRes.ok) {
const error = await promoteRes.json() as { error: string }
throw new Error(error.error || "设置失败")
throw new Error(error.error || t("updateFailed"))
}
toast({
title: "设置成功",
description: `已将用户 ${data.user.username || data.user.email} 设为${roleNames[targetRole]}`,
title: t("updateSuccess"),
description: `${data.user.username || data.user.email} - ${roleNames[targetRole]}`,
})
setSearchText("")
} catch (error) {
toast({
title: "设置失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("updateFailed"),
description: error instanceof Error ? error.message : t("updateFailed"),
variant: "destructive"
})
} finally {
@@ -110,7 +113,7 @@ export function PromotePanel() {
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-2 mb-6">
<Icon className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
<div className="space-y-4">
@@ -119,7 +122,7 @@ export function PromotePanel() {
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="输入用户名或邮箱"
placeholder={t("searchPlaceholder")}
/>
</div>
<Select value={targetRole} onValueChange={(value) => setTargetRole(value as RoleWithoutEmperor)}>
@@ -130,19 +133,19 @@ export function PromotePanel() {
<SelectItem value={ROLES.DUKE}>
<div className="flex items-center gap-2">
<Gem className="w-4 h-4" />
{roleNames[ROLES.DUKE]}
</div>
</SelectItem>
<SelectItem value={ROLES.KNIGHT}>
<div className="flex items-center gap-2">
<Sword className="w-4 h-4" />
{roleNames[ROLES.KNIGHT]}
</div>
</SelectItem>
<SelectItem value={ROLES.CIVILIAN}>
<div className="flex items-center gap-2">
<User2 className="w-4 h-4" />
{roleNames[ROLES.CIVILIAN]}
</div>
</SelectItem>
</SelectContent>
@@ -157,7 +160,7 @@ export function PromotePanel() {
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
`设为${roleNames[targetRole]}`
`${t("promote")} ${roleNames[targetRole]}`
)}
</Button>
</div>

View File

@@ -2,6 +2,7 @@
"use client"
import { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -16,6 +17,10 @@ import {
} from "@/components/ui/tooltip"
export function WebhookConfig() {
const t = useTranslations("profile.webhook")
const tCommon = useTranslations("common.actions")
const tMessages = useTranslations("emails.messages")
const tApiKey = useTranslations("profile.apiKey")
const [enabled, setEnabled] = useState(false)
const [url, setUrl] = useState("")
const [loading, setLoading] = useState(false)
@@ -42,7 +47,7 @@ export function WebhookConfig() {
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
<div>
<p className="text-sm text-muted-foreground">...</p>
<p className="text-sm text-muted-foreground">{tMessages("loading")}</p>
</div>
</div>
)
@@ -60,16 +65,16 @@ export function WebhookConfig() {
body: JSON.stringify({ url, enabled })
})
if (!res.ok) throw new Error("Failed to save")
if (!res.ok) throw new Error(t("saveFailed"))
toast({
title: "保存成功",
description: "Webhook 配置已更新"
title: t("saveSuccess"),
description: t("saveSuccess")
})
} catch (_error) {
toast({
title: "保存失败",
description: "请稍后重试",
title: t("saveFailed"),
description: t("saveFailed"),
variant: "destructive"
})
} finally {
@@ -88,16 +93,16 @@ export function WebhookConfig() {
body: JSON.stringify({ url })
})
if (!res.ok) throw new Error("测试失败")
if (!res.ok) throw new Error(t("testFailed"))
toast({
title: "测试成功",
description: "Webhook 调用成功,请检查目标服务器是否收到请求"
title: t("testSuccess"),
description: t("testSuccess")
})
} catch (_error) {
toast({
title: "测试失败",
description: "请检查 URL 是否正确且可访问",
title: t("testFailed"),
description: t("testFailed"),
variant: "destructive"
})
} finally {
@@ -109,9 +114,9 @@ export function WebhookConfig() {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Webhook</Label>
<Label>{t("enable")}</Label>
<div className="text-sm text-muted-foreground">
URL
{t("description")}
</div>
</div>
<Switch
@@ -123,11 +128,11 @@ export function WebhookConfig() {
{enabled && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhook-url">Webhook URL</Label>
<Label htmlFor="webhook-url">{t("url")}</Label>
<div className="flex gap-2">
<Input
id="webhook-url"
placeholder="https://example.com/webhook"
placeholder={t("urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
type="url"
@@ -137,7 +142,7 @@ export function WebhookConfig() {
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"保存"
tCommon("save")
)}
</Button>
<TooltipProvider>
@@ -157,13 +162,13 @@ export function WebhookConfig() {
</Button>
</TooltipTrigger>
<TooltipContent>
<p> Webhook</p>
<p>{t("test")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-xs text-muted-foreground">
URL POST ,
{t("description2")}
</p>
</div>
@@ -174,26 +179,26 @@ export function WebhookConfig() {
onClick={() => setShowDocs(!showDocs)}
>
{showDocs ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{t("description3")}
</button>
{showDocs && (
<div className="rounded-md bg-muted p-4 text-sm space-y-3">
<p> URL POST :</p>
<p>{t("docs.intro")}</p>
<pre className="bg-background p-2 rounded text-xs">
Content-Type: application/json{'\n'}
X-Webhook-Event: new_message
</pre>
<p>:</p>
<p>{t("docs.exampleBody")}</p>
<pre className="bg-background p-2 rounded text-xs overflow-auto">
{`{
"emailId": "email-uuid",
"messageId": "message-uuid",
"fromAddress": "sender@example.com",
"subject": "邮件主题",
"content": "邮件文本内容",
"html": "邮件HTML内容",
"subject": "${t("docs.subject")}",
"content": "${t("docs.content")}",
"html": "${t("docs.html")}",
"receivedAt": "2024-01-01T12:00:00.000Z",
"toAddress": "your-email@${window.location.host}"
}`}

View File

@@ -1,5 +1,6 @@
"use client"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Settings } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
@@ -16,6 +17,8 @@ import {
import { EMAIL_CONFIG } from "@/config"
export function WebsiteConfigPanel() {
const t = useTranslations("profile.website")
const tCard = useTranslations("profile.card")
const [defaultRole, setDefaultRole] = useState<string>("")
const [emailDomains, setEmailDomains] = useState<string>("")
const [adminContact, setAdminContact] = useState<string>("")
@@ -58,16 +61,16 @@ export function WebsiteConfigPanel() {
}),
})
if (!res.ok) throw new Error("保存失败")
if (!res.ok) throw new Error(t("saveFailed"))
toast({
title: "保存成功",
description: "网站设置已更新",
title: t("saveSuccess"),
description: t("saveSuccess"),
})
} catch (error) {
toast({
title: "保存失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("saveFailed"),
description: error instanceof Error ? error.message : t("saveFailed"),
variant: "destructive",
})
} finally {
@@ -79,48 +82,48 @@ export function WebsiteConfigPanel() {
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-2 mb-6">
<Settings className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
<div className="space-y-4">
<div className="flex items-center gap-4">
<span className="text-sm">:</span>
<span className="text-sm">{t("defaultRole")}:</span>
<Select value={defaultRole} onValueChange={setDefaultRole}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ROLES.DUKE}></SelectItem>
<SelectItem value={ROLES.KNIGHT}></SelectItem>
<SelectItem value={ROLES.CIVILIAN}></SelectItem>
<SelectItem value={ROLES.DUKE}>{tCard("roles.DUKE")}</SelectItem>
<SelectItem value={ROLES.KNIGHT}>{tCard("roles.KNIGHT")}</SelectItem>
<SelectItem value={ROLES.CIVILIAN}>{tCard("roles.CIVILIAN")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">:</span>
<span className="text-sm">{t("emailDomains")}:</span>
<div className="flex-1">
<Input
value={emailDomains}
onChange={(e) => setEmailDomains(e.target.value)}
placeholder="多个域名用逗号分隔,如: moemail.app,bitibiti.com"
placeholder={t("emailDomainsPlaceholder")}
/>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">:</span>
<span className="text-sm">{t("adminContact")}:</span>
<div className="flex-1">
<Input
value={adminContact}
onChange={(e) => setAdminContact(e.target.value)}
placeholder="如: 微信号、邮箱等"
placeholder={t("adminContactPlaceholder")}
/>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">:</span>
<span className="text-sm">{t("maxEmails")}:</span>
<div className="flex-1">
<Input
type="number"
@@ -128,7 +131,7 @@ export function WebsiteConfigPanel() {
max="100"
value={maxEmails}
onChange={(e) => setMaxEmails(e.target.value)}
placeholder={`默认为 ${EMAIL_CONFIG.MAX_ACTIVE_EMAILS}`}
placeholder={`${EMAIL_CONFIG.MAX_ACTIVE_EMAILS}`}
/>
</div>
</div>
@@ -138,7 +141,7 @@ export function WebsiteConfigPanel() {
disabled={loading}
className="w-full"
>
{t("save")}
</Button>
</div>
</div>