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
+2
View File
@@ -52,6 +52,7 @@
- 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知
- 🛡️ **权限系统**:支持基于角色的权限控制系统
- 🔑 **OpenAPI**:支持通过 API Key 访问 OpenAPI
- 🌍 **多语言支持**:支持中文和英文界面,可自由切换
## 技术栈
@@ -64,6 +65,7 @@
- **邮件处理**: [Cloudflare Email Workers](https://developers.cloudflare.com/email-routing/)
- **类型安全**: [TypeScript](https://www.typescriptlang.org/)
- **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
- **国际化**: [next-intl](https://next-intl-docs.vercel.app/) 支持多语言
## 本地运行
+148
View File
@@ -0,0 +1,148 @@
import { NextIntlClientProvider } from "next-intl"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { i18n, type Locale } from "@/i18n/config"
import type { Metadata, Viewport } from "next"
import { FloatMenu } from "@/components/float-menu"
import { ThemeProvider } from "@/components/theme/theme-provider"
import { Toaster } from "@/components/ui/toaster"
import { cn } from "@/lib/utils"
import { zpix } from "../fonts"
import "../globals.css"
import { Providers } from "../providers"
export const runtime = "edge"
export const viewport: Viewport = {
themeColor: '#826DD9',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
async function getMessages(locale: Locale) {
try {
const common = (await import(`@/i18n/messages/${locale}/common.json`)).default
const home = (await import(`@/i18n/messages/${locale}/home.json`)).default
const auth = (await import(`@/i18n/messages/${locale}/auth.json`)).default
const metadata = (await import(`@/i18n/messages/${locale}/metadata.json`)).default
const emails = (await import(`@/i18n/messages/${locale}/emails.json`)).default
const profile = (await import(`@/i18n/messages/${locale}/profile.json`)).default
return { common, home, auth, metadata, emails, profile }
} catch (error) {
console.error(`Failed to load messages for locale ${locale}:`, error)
return { common: {}, home: {}, auth: {}, metadata: {}, emails: {}, profile: {} }
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const t = await getTranslations({ locale, namespace: "metadata" })
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://moemail.app"
// Generate hreflang links for all supported locales
const languages: Record<string, string> = {}
i18n.locales.forEach((loc) => {
languages[loc] = `${baseUrl}/${loc}`
})
return {
title: t("title"),
description: t("description"),
keywords: t("keywords"),
authors: [{ name: "SoftMoe Studio" }],
creator: "SoftMoe Studio",
publisher: "SoftMoe Studio",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
type: "website",
locale: locale === "zh-CN" ? "zh_CN" : locale,
url: `${baseUrl}/${locale}`,
title: t("title"),
description: t("description"),
siteName: "MoeMail",
},
twitter: {
card: "summary_large_image",
title: t("title"),
description: t("description"),
},
alternates: {
canonical: `${baseUrl}/${locale}`,
languages,
},
manifest: '/manifest.json',
icons: [
{ rel: 'apple-touch-icon', url: '/icons/icon-192x192.png' },
],
}
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
if (!i18n.locales.includes(locale)) {
notFound()
}
const messages = await getMessages(locale)
return (
<html lang={locale} suppressHydrationWarning>
<head>
<meta name="application-name" content="MoeMail" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="MoeMail" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body
className={cn(
zpix.variable,
"font-zpix min-h-screen antialiased",
"bg-background text-foreground",
"transition-colors duration-300"
)}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
storageKey="temp-mail-theme"
>
<Providers>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
<FloatMenu />
</NextIntlClientProvider>
</Providers>
<Toaster />
</ThemeProvider>
</body>
</html>
)
}
@@ -1,14 +1,21 @@
import { LoginForm } from "@/components/auth/login-form"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function LoginPage() {
export default async function LoginPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
if (session?.user) {
redirect("/")
redirect(`/${locale}`)
}
return (
@@ -16,4 +23,5 @@ export default async function LoginPage() {
<LoginForm />
</div>
)
}
}
+11 -3
View File
@@ -5,14 +5,21 @@ import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { checkPermission } from "@/lib/auth"
import { PERMISSIONS } from "@/lib/permissions"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function MoePage() {
export default async function MoePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
if (!session?.user) {
redirect("/")
redirect(`/${locale}`)
}
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_EMAIL)
@@ -28,4 +35,5 @@ export default async function MoePage() {
</div>
</div>
)
}
}
+19 -9
View File
@@ -3,11 +3,20 @@ import { auth } from "@/lib/auth"
import { Shield, Mail, Clock } from "lucide-react"
import { ActionButton } from "@/components/home/action-button"
import { FeatureCard } from "@/components/home/feature-card"
import { getTranslations } from "next-intl/server"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function Home() {
export default async function Home({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
const t = await getTranslations({ locale, namespace: "home" })
return (
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 h-screen">
@@ -21,29 +30,29 @@ export default async function Home() {
<div className="space-y-4">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-wider">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
MoeMail
{t("title")}
</span>
</h1>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 tracking-wide">
{t("subtitle")}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 px-4 sm:px-0">
<FeatureCard
icon={<Shield className="w-5 h-5" />}
title="隐私保护"
description="保护您的真实邮箱地址"
title={t("features.privacy.title")}
description={t("features.privacy.description")}
/>
<FeatureCard
icon={<Mail className="w-5 h-5" />}
title="即时收件"
description="实时接收邮件通知"
title={t("features.instant.title")}
description={t("features.instant.description")}
/>
<FeatureCard
icon={<Clock className="w-5 h-5" />}
title="自动过期"
description="到期自动失效"
title={t("features.expiry.title")}
description={t("features.expiry.description")}
/>
</div>
@@ -57,3 +66,4 @@ export default async function Home() {
</div>
)
}
@@ -2,14 +2,21 @@ import { Header } from "@/components/layout/header"
import { ProfileCard } from "@/components/profile/profile-card"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function ProfilePage() {
export default async function ProfilePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
if (!session?.user) {
redirect("/")
redirect(`/${locale}`)
}
return (
@@ -22,4 +29,5 @@ export default async function ProfilePage() {
</div>
</div>
)
}
}
+35 -33
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>
+9 -6
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>
)
+30 -23
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>
+19 -16
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>
@@ -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>
+17 -13
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>
+15 -12
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>
+20 -16
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>
@@ -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
+5 -2
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>
+5 -2
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>
)
}
+2
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>
@@ -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>
)
}
+9 -5
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>
+50 -45
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>
+27 -22
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>
+21 -14
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>
+24 -21
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>
+29 -24
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}"
}`}
+20 -17
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>
+11
View File
@@ -0,0 +1,11 @@
export const locales = ['en', 'zh-CN'] as const
export type Locale = typeof locales[number]
export const defaultLocale: Locale = 'en'
export const i18n = {
locales,
defaultLocale,
localePrefix: 'as-needed',
}
+43
View File
@@ -0,0 +1,43 @@
{
"signButton": {
"login": "Login / Sign Up",
"logout": "Logout",
"userAvatar": "User Avatar",
"linked": "Linked"
},
"loginForm": {
"title": "Welcome to MoeMail",
"subtitle": "Cute Temporary Email Service (。・∀・)",
"tabs": {
"login": "Login",
"register": "Sign Up"
},
"fields": {
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm Password"
},
"actions": {
"login": "Login",
"register": "Sign Up",
"or": "OR",
"githubLogin": "Login with GitHub"
},
"errors": {
"usernameRequired": "Please enter username",
"passwordRequired": "Please enter password",
"confirmPasswordRequired": "Please confirm password",
"usernameInvalid": "Username cannot contain @ symbol",
"passwordTooShort": "Password must be at least 8 characters",
"passwordMismatch": "Passwords do not match"
},
"toast": {
"loginFailed": "Login Failed",
"loginFailedDesc": "Incorrect username or password",
"registerFailed": "Registration Failed",
"registerFailedDesc": "Please try again later",
"autoLoginFailed": "Auto-login failed, please login manually"
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"app": {
"name": "MoeMail"
},
"lang": {
"en": "English",
"zhCN": "简体中文",
"switch": "Switch Language"
},
"actions": {
"ok": "OK",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete"
},
"nav": {
"home": "Home",
"login": "Login",
"profile": "Profile",
"logout": "Logout",
"backToMailbox": "Back to Mailbox"
},
"github": "Get Source Code"
}
+87
View File
@@ -0,0 +1,87 @@
{
"noPermission": {
"title": "Insufficient Permission",
"description": "You don't have permission to access this page. Please contact the website administrator.",
"adminContact": "Admin Contact",
"backToHome": "Back to Home"
},
"layout": {
"myEmails": "My Emails",
"selectEmail": "Select an email to view messages",
"messageContent": "Message Content",
"selectMessage": "Select a message to view details",
"backToEmailList": "← Back to Email List",
"backToMessageList": "← Back to Message List"
},
"list": {
"emailCount": "{count}/{max} emails",
"emailCountUnlimited": "{count}/∞ emails",
"loading": "Loading...",
"loadingMore": "Loading more...",
"noEmails": "No emails yet, create one!",
"expiresAt": "Expires",
"permanent": "Permanent",
"deleteConfirm": "Confirm Delete",
"deleteDescription": "Are you sure you want to delete {email}? This will also delete all messages in this mailbox and cannot be undone.",
"deleteSuccess": "Email deleted successfully",
"deleteFailed": "Failed to delete email",
"error": "Error",
"success": "Success"
},
"create": {
"title": "Create Email",
"name": "Email Prefix",
"namePlaceholder": "Leave empty for random generation",
"domain": "Domain",
"domainPlaceholder": "Select a domain",
"expiryTime": "Validity Period",
"oneHour": "1 Hour",
"oneDay": "1 Day",
"threeDays": "3 Days",
"permanent": "Permanent",
"create": "Create",
"creating": "Creating...",
"success": "Email created successfully",
"failed": "Failed to create email"
},
"messages": {
"received": "Received",
"sent": "Sent",
"noMessages": "No messages yet",
"from": "From",
"to": "To",
"subject": "Subject",
"date": "Date",
"loading": "Loading...",
"loadingMore": "Loading more..."
},
"send": {
"title": "Send Email",
"from": "From",
"to": "To",
"toPlaceholder": "Recipient email address",
"subject": "Subject",
"subjectPlaceholder": "Email subject",
"content": "Content",
"contentPlaceholder": "Email content (supports HTML)",
"send": "Send",
"sending": "Sending...",
"success": "Email sent successfully",
"failed": "Failed to send email",
"dailyLimitReached": "Daily sending limit reached",
"dailyLimit": "Daily Limit: {count}/{max}",
"dailyLimitUnit": "emails/day"
},
"messageView": {
"loading": "Loading message details...",
"loadError": "Failed to load message details",
"networkError": "Network error, please try again later",
"retry": "Click to retry",
"from": "From",
"to": "To",
"time": "Time",
"htmlFormat": "HTML Format",
"textFormat": "Plain Text Format"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"title": "MoeMail",
"subtitle": "Cute Temporary Email Service",
"features": {
"privacy": {
"title": "Privacy Protection",
"description": "Protect your real email address"
},
"instant": {
"title": "Instant Delivery",
"description": "Receive emails in real-time"
},
"expiry": {
"title": "Auto Expiry",
"description": "Automatically expires when due"
}
},
"actions": {
"enterMailbox": "Enter Mailbox",
"getStarted": "Get Started"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"title": "MoeMail - Cute Temporary Email Service",
"description": "Secure, fast, and disposable temporary email addresses. Protect your privacy and stay away from spam. Instant delivery with automatic expiration.",
"keywords": "temporary email, disposable email, anonymous email, privacy protection, spam filter, instant delivery, auto expiry, secure email, verification email, temporary account, cute email, email service, privacy security, MoeMail"
}
+123
View File
@@ -0,0 +1,123 @@
{
"title": "Profile",
"card": {
"title": "User Information",
"name": "Name",
"role": "Role",
"roles": {
"EMPEROR": "Emperor",
"DUKE": "Duke",
"KNIGHT": "Knight",
"CIVILIAN": "Civilian"
}
},
"apiKey": {
"title": "API Key Management",
"description": "Create and manage API keys for accessing OpenAPI",
"create": "Create API Key",
"name": "Key Name",
"namePlaceholder": "Enter key name",
"key": "API Key",
"createdAt": "Created At",
"copy": "Copy",
"delete": "Delete",
"noKeys": "No API keys yet",
"createSuccess": "API key created successfully",
"createFailed": "Failed to create API key",
"deleteConfirm": "Confirm Delete",
"deleteDescription": "Are you sure you want to delete API key {name}? This action cannot be undone.",
"deleteSuccess": "API key deleted successfully",
"deleteFailed": "Failed to delete API key",
"viewDocs": "View Documentation",
"docs": {
"getConfig": "Get System Config",
"generateEmail": "Generate Temp Email",
"getEmails": "Get Email List",
"getMessages": "Get Message List",
"getMessage": "Get Single Message",
"notes": "Notes:",
"note1": "Replace YOUR_API_KEY with your actual API Key",
"note2": "/api/config endpoint provides system configuration including available email domains",
"note3": "emailId is the unique identifier for an email",
"note4": "messageId is the unique identifier for a message",
"note5": "expiryTime is the validity period in milliseconds: 3600000 (1 hour), 86400000 (1 day), 604800000 (7 days), 0 (permanent)",
"note6": "domain is the email domain, get available domains from /api/config endpoint",
"note7": "cursor is for pagination, get nextCursor from previous response",
"note8": "All requests require X-API-Key header"
}
},
"emailService": {
"title": "Resend Email Service Configuration",
"configRoleLabel": "Configurable Role Permissions",
"enable": "Enable Email Service",
"fixedRoleLimits": "Fixed Role Limits",
"emperorLimit": "Emperor can send unlimited emails without any restrictions",
"civilianLimit": "Cannot send emails",
"enableDescription": "When enabled, emails will be sent using Resend",
"apiKey": "Resend API Key",
"apiKeyPlaceholder": "Enter Resend API Key",
"dailyLimit": "Daily Limit",
"roleLimits": "Roles allowed to use sending feature",
"save": "Save Configuration",
"saving": "Saving...",
"saveSuccess": "Configuration saved successfully",
"saveFailed": "Failed to save configuration",
"unlimited": "Unlimited",
"disabled": "Sending permission not enabled",
"enabled": "Sending permission enabled"
},
"webhook": {
"title": "Webhook Configuration",
"description": "Notify specified URL when new emails arrive",
"description2": "We will send a POST request to this URL with information about the new email",
"description3": "View data format documentation",
"enable": "Enable Webhook",
"url": "Webhook URL",
"urlPlaceholder": "Enter webhook URL",
"test": "Test",
"testing": "Testing...",
"save": "Save Configuration",
"saving": "Saving...",
"saveSuccess": "Configuration saved successfully",
"saveFailed": "Failed to save configuration",
"testSuccess": "Webhook test successful",
"testFailed": "Webhook test failed",
"docs": {
"intro": "When a new email is received, we will send a POST request to the configured URL with the following headers:",
"exampleBody": "Request body example:",
"subject": "Email Subject",
"content": "Email text content",
"html": "Email HTML content"
}
},
"website": {
"title": "Website Configuration",
"description": "Configure website settings (Emperor only)",
"defaultRole": "Default Role for New Users",
"emailDomains": "Email Domains",
"emailDomainsPlaceholder": "Separate multiple domains with commas",
"adminContact": "Admin Contact",
"adminContactPlaceholder": "Email or other contact method",
"maxEmails": "Max Emails per User",
"save": "Save Configuration",
"saving": "Saving...",
"saveSuccess": "Configuration saved successfully",
"saveFailed": "Failed to save configuration"
},
"promote": {
"title": "Role Management",
"description": "Manage user roles (Emperor only)",
"search": "Search Users",
"searchPlaceholder": "Enter username or email",
"username": "Username",
"email": "Email",
"role": "Role",
"actions": "Actions",
"promote": "Set as",
"noUsers": "No users found",
"loading": "Loading...",
"updateSuccess": "User role updated successfully",
"updateFailed": "Failed to update user role"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"signButton": {
"login": "登录/注册",
"logout": "登出",
"userAvatar": "用户头像",
"linked": "已关联"
},
"loginForm": {
"title": "欢迎使用 MoeMail",
"subtitle": "萌萌哒临时邮箱服务 (。・∀・)ノ",
"tabs": {
"login": "登录",
"register": "注册"
},
"fields": {
"username": "用户名",
"password": "密码",
"confirmPassword": "确认密码"
},
"actions": {
"login": "登录",
"register": "注册",
"or": "或者",
"githubLogin": "使用 GitHub 账号登录"
},
"errors": {
"usernameRequired": "请输入用户名",
"passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码",
"usernameInvalid": "用户名不能包含 @ 符号",
"passwordTooShort": "密码长度必须大于等于8位",
"passwordMismatch": "两次输入的密码不一致"
},
"toast": {
"loginFailed": "登录失败",
"loginFailedDesc": "用户名或密码错误",
"registerFailed": "注册失败",
"registerFailedDesc": "请稍后重试",
"autoLoginFailed": "自动登录失败,请手动登录"
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"app": {
"name": "MoeMail"
},
"lang": {
"en": "English",
"zhCN": "简体中文",
"switch": "切换语言"
},
"actions": {
"ok": "确定",
"cancel": "取消",
"save": "保存",
"delete": "删除"
},
"nav": {
"home": "首页",
"login": "登录",
"profile": "个人中心",
"logout": "退出登录",
"backToMailbox": "返回邮箱"
},
"github": "获取网站源代码"
}
+87
View File
@@ -0,0 +1,87 @@
{
"noPermission": {
"title": "权限不足",
"description": "你没有权限访问此页面,请联系网站管理员",
"adminContact": "管理员联系方式",
"backToHome": "返回首页"
},
"layout": {
"myEmails": "我的邮箱",
"selectEmail": "选择邮箱查看消息",
"messageContent": "邮件内容",
"selectMessage": "选择邮件查看详情",
"backToEmailList": "← 返回邮箱列表",
"backToMessageList": "← 返回消息列表"
},
"list": {
"emailCount": "{count}/{max} 个邮箱",
"emailCountUnlimited": "{count}/∞ 个邮箱",
"loading": "加载中...",
"loadingMore": "加载更多...",
"noEmails": "还没有邮箱,创建一个吧!",
"expiresAt": "过期时间",
"permanent": "永久有效",
"deleteConfirm": "确认删除",
"deleteDescription": "确定要删除邮箱 {email} 吗?此操作将同时删除该邮箱中的所有邮件,且不可恢复。",
"deleteSuccess": "邮箱已删除",
"deleteFailed": "删除邮箱失败",
"error": "错误",
"success": "成功"
},
"create": {
"title": "创建邮箱",
"name": "邮箱前缀",
"namePlaceholder": "留空则随机生成",
"domain": "域名",
"domainPlaceholder": "选择域名",
"expiryTime": "有效期",
"oneHour": "1 小时",
"oneDay": "1 天",
"threeDays": "3 天",
"permanent": "永久",
"create": "创建",
"creating": "创建中...",
"success": "邮箱创建成功",
"failed": "创建邮箱失败"
},
"messages": {
"received": "收件箱",
"sent": "已发送",
"noMessages": "暂无邮件",
"from": "发件人",
"to": "收件人",
"subject": "主题",
"date": "日期",
"loading": "加载中...",
"loadingMore": "加载更多..."
},
"send": {
"title": "发送邮件",
"from": "发件人",
"to": "收件人",
"toPlaceholder": "收件人邮箱地址",
"subject": "主题",
"subjectPlaceholder": "邮件主题",
"content": "内容",
"contentPlaceholder": "邮件内容(支持 HTML",
"send": "发送",
"sending": "发送中...",
"success": "邮件发送成功",
"failed": "发送邮件失败",
"dailyLimitReached": "已达每日发送上限",
"dailyLimit": "每日限额:{count}/{max}",
"dailyLimitUnit": "封/天"
},
"messageView": {
"loading": "加载邮件详情...",
"loadError": "获取邮件详情失败",
"networkError": "网络错误,请稍后重试",
"retry": "点击重试",
"from": "发件人",
"to": "收件人",
"time": "时间",
"htmlFormat": "HTML 格式",
"textFormat": "纯文本格式"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"title": "MoeMail",
"subtitle": "萌萌哒临时邮箱服务",
"features": {
"privacy": {
"title": "隐私保护",
"description": "保护您的真实邮箱地址"
},
"instant": {
"title": "即时收件",
"description": "实时接收邮件通知"
},
"expiry": {
"title": "自动过期",
"description": "到期自动失效"
}
},
"actions": {
"enterMailbox": "进入邮箱",
"getStarted": "开始使用"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"title": "MoeMail - 萌萌哒临时邮箱服务",
"description": "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
"keywords": "临时邮箱, 一次性邮箱, 匿名邮箱, 隐私保护, 垃圾邮件过滤, 即时收件, 自动过期, 安全邮箱, 注册验证, 临时账号, 萌系邮箱, 电子邮件, 隐私安全, 邮件服务, MoeMail"
}
+123
View File
@@ -0,0 +1,123 @@
{
"title": "个人中心",
"card": {
"title": "用户信息",
"name": "用户名",
"role": "角色",
"roles": {
"EMPEROR": "皇帝",
"DUKE": "公爵",
"KNIGHT": "骑士",
"CIVILIAN": "平民"
}
},
"apiKey": {
"title": "API Key 管理",
"description": "创建和管理用于访问 OpenAPI 的 API 密钥",
"create": "创建 API Key",
"name": "密钥名称",
"namePlaceholder": "输入密钥名称",
"key": "API Key",
"createdAt": "创建时间",
"copy": "复制",
"delete": "删除",
"noKeys": "暂无 API Key",
"createSuccess": "API Key 创建成功",
"createFailed": "创建 API Key 失败",
"deleteConfirm": "确认删除",
"deleteDescription": "确定要删除 API Key {name} 吗?此操作不可恢复。",
"deleteSuccess": "API Key 已删除",
"deleteFailed": "删除 API Key 失败",
"viewDocs": "查看使用文档",
"docs": {
"getConfig": "获取系统配置",
"generateEmail": "生成临时邮箱",
"getEmails": "获取邮箱列表",
"getMessages": "获取邮件列表",
"getMessage": "获取单封邮件",
"notes": "注意:",
"note1": "请将 YOUR_API_KEY 替换为你的实际 API Key",
"note2": "/api/config 接口可获取系统配置,包括可用的邮箱域名列表",
"note3": "emailId 是邮箱的唯一标识符",
"note4": "messageId 是邮件的唯一标识符",
"note5": "expiryTime 是邮箱的有效期(毫秒),可选值:3600000(1小时)、864000001天)、6048000007天)、0(永久)",
"note6": "domain 是邮箱域名,可通过 /api/config 接口获取可用域名列表",
"note7": "cursor 用于分页,从上一次请求的响应中获取 nextCursor",
"note8": "所有请求都需要包含 X-API-Key 请求头"
}
},
"emailService": {
"title": "Resend 发件服务配置",
"configRoleLabel": "可配置的角色权限",
"enable": "启用邮件服务",
"fixedRoleLimits": "固定权限规则",
"emperorLimit": "皇帝可以无限发件,不受任何限制",
"civilianLimit": "永远不能发件",
"enableDescription": "开启后将使用 Resend 发送邮件",
"apiKey": "Resend API Key",
"apiKeyPlaceholder": "输入 Resend API Key",
"dailyLimit": "每日限额",
"roleLimits": "允许使用发件功能的角色",
"save": "保存配置",
"saving": "保存中...",
"saveSuccess": "配置保存成功",
"saveFailed": "保存配置失败",
"unlimited": "无限制",
"disabled": "未启用发件权限",
"enabled": "已启用发件权限"
},
"webhook": {
"title": "Webhook 配置",
"description": "当收到新邮件时通知指定的 URL",
"description2": "我们会向此 URL 发送 POST 请求,包含新邮件的相关信息",
"description3": "查看数据格式说明",
"enable": "启用 Webhook",
"url": "Webhook URL",
"urlPlaceholder": "输入 webhook URL",
"test": "测试",
"testing": "测试中...",
"save": "保存配置",
"saving": "保存中...",
"saveSuccess": "配置保存成功",
"saveFailed": "保存配置失败",
"testSuccess": "Webhook 测试成功",
"testFailed": "Webhook 测试失败",
"docs": {
"intro": "当收到新邮件时,我们会向配置的 URL 发送 POST 请求,请求头包含:",
"exampleBody": "请求体示例:",
"subject": "邮件主题",
"content": "邮件文本内容",
"html": "邮件HTML内容"
}
},
"website": {
"title": "网站配置",
"description": "配置网站设置(仅皇帝可用)",
"defaultRole": "新用户默认角色",
"emailDomains": "邮箱域名",
"emailDomainsPlaceholder": "多个域名用逗号分隔",
"adminContact": "管理员联系方式",
"adminContactPlaceholder": "邮箱或其他联系方式",
"maxEmails": "每个用户最大邮箱数",
"save": "保存配置",
"saving": "保存中...",
"saveSuccess": "配置保存成功",
"saveFailed": "保存配置失败"
},
"promote": {
"title": "角色管理",
"description": "管理用户角色(仅皇帝可用)",
"search": "搜索用户",
"searchPlaceholder": "输入用户名或邮箱",
"username": "用户名",
"email": "邮箱",
"role": "角色",
"actions": "操作",
"promote": "设为",
"noUsers": "未找到用户",
"loading": "加载中...",
"updateSuccess": "用户角色更新成功",
"updateFailed": "更新用户角色失败"
}
}
+18
View File
@@ -0,0 +1,18 @@
import {getRequestConfig} from 'next-intl/server'
import {i18n} from '@/i18n/config'
export default getRequestConfig(async ({locale}) => {
const safeLocale = (i18n.locales.includes(locale as any) ? locale : i18n.defaultLocale) as string
try {
const common = (await import(`@/i18n/messages/${safeLocale}/common.json`)).default
const home = (await import(`@/i18n/messages/${safeLocale}/home.json`)).default
const auth = (await import(`@/i18n/messages/${safeLocale}/auth.json`)).default
const metadata = (await import(`@/i18n/messages/${safeLocale}/metadata.json`)).default
const emails = (await import(`@/i18n/messages/${safeLocale}/emails.json`)).default
const profile = (await import(`@/i18n/messages/${safeLocale}/profile.json`)).default
return {locale: safeLocale, messages: {common, home, auth, metadata, emails, profile}}
} catch {
return {locale: safeLocale, messages: {common: {}, home: {}, auth: {}, metadata: {}, emails: {}, profile: {}}}
}
})
+1 -101
View File
@@ -1,107 +1,7 @@
import { ThemeProvider } from "@/components/theme/theme-provider"
import { Toaster } from "@/components/ui/toaster"
import { cn } from "@/lib/utils"
import type { Metadata, Viewport } from "next"
import { zpix } from "./fonts"
import "./globals.css"
import { Providers } from "./providers"
import { FloatMenu } from "@/components/float-menu"
export const metadata: Metadata = {
title: "MoeMail - 萌萌哒临时邮箱服务",
description: "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
keywords: [
"临时邮箱",
"一次性邮箱",
"匿名邮箱",
"隐私保护",
"垃圾邮件过滤",
"即时收件",
"自动过期",
"安全邮箱",
"注册验证",
"临时账号",
"萌系邮箱",
"电子邮件",
"隐私安全",
"邮件服务",
"MoeMail"
].join(", "),
authors: [{ name: "SoftMoe Studio" }],
creator: "SoftMoe Studio",
publisher: "SoftMoe Studio",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
type: "website",
locale: "zh_CN",
url: "https://moemail.app",
title: "MoeMail - 萌萌哒临时邮箱服务",
description: "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
siteName: "MoeMail",
},
twitter: {
card: "summary_large_image",
title: "MoeMail - 萌萌哒临时邮箱服务",
description: "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
},
manifest: '/manifest.json',
icons: [
{ rel: 'apple-touch-icon', url: '/icons/icon-192x192.png' },
],
}
export const viewport: Viewport = {
themeColor: '#826DD9',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh" suppressHydrationWarning>
<head>
<meta name="application-name" content="MoeMail" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="MoeMail" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body
className={cn(
zpix.variable,
"font-zpix min-h-screen antialiased",
"bg-background text-foreground",
"transition-colors duration-300"
)}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
storageKey="temp-mail-theme"
>
<Providers>
{children}
</Providers>
<Toaster />
<FloatMenu />
</ThemeProvider>
</body>
</html>
)
return children
}
+47 -28
View File
@@ -1,5 +1,6 @@
import { auth } from "@/lib/auth"
import { NextResponse } from "next/server"
import { i18n } from "@/i18n/config"
import { PERMISSIONS } from "@/lib/permissions"
import { checkPermission } from "@/lib/auth"
import { Permission } from "@/lib/permissions"
@@ -14,40 +15,57 @@ const API_PERMISSIONS: Record<string, Permission> = {
}
export async function middleware(request: Request) {
const pathname = new URL(request.url).pathname
const url = new URL(request.url)
const pathname = url.pathname
// API Key 认证
request.headers.delete("X-User-Id")
const apiKey = request.headers.get("X-API-Key")
if (apiKey) {
return handleApiKeyAuth(apiKey, pathname)
}
if (pathname.startsWith('/api')) {
if (pathname.startsWith('/api/auth')) {
return NextResponse.next()
}
// Session 认证
const session = await auth()
if (!session?.user) {
return NextResponse.json(
{ error: "未授权" },
{ status: 401 }
)
}
request.headers.delete("X-User-Id")
const apiKey = request.headers.get("X-API-Key")
if (apiKey) {
return handleApiKeyAuth(apiKey, pathname)
}
if (pathname === '/api/config' && request.method === 'GET') {
const session = await auth()
if (!session?.user) {
return NextResponse.json(
{ error: "未授权" },
{ status: 401 }
)
}
if (pathname === '/api/config' && request.method === 'GET') {
return NextResponse.next()
}
for (const [route, permission] of Object.entries(API_PERMISSIONS)) {
if (pathname.startsWith(route)) {
const hasAccess = await checkPermission(permission)
if (!hasAccess) {
return NextResponse.json(
{ error: "权限不足" },
{ status: 403 }
)
}
break
}
}
return NextResponse.next()
}
for (const [route, permission] of Object.entries(API_PERMISSIONS)) {
if (pathname.startsWith(route)) {
const hasAccess = await checkPermission(permission)
if (!hasAccess) {
return NextResponse.json(
{ error: "权限不足" },
{ status: 403 }
)
}
break
}
// Pages: 语言前缀
const segments = pathname.split('/')
const maybeLocale = segments[1]
const hasLocalePrefix = i18n.locales.includes(maybeLocale as any)
if (!hasLocalePrefix) {
const cookieLocale = request.headers.get('Cookie')?.match(/NEXT_LOCALE=([^;]+)/)?.[1]
const targetLocale = (cookieLocale && i18n.locales.includes(cookieLocale as any)) ? cookieLocale : i18n.defaultLocale
const redirectURL = new URL(`/${targetLocale}${pathname}${url.search}`, request.url)
return NextResponse.redirect(redirectURL)
}
return NextResponse.next()
@@ -55,6 +73,7 @@ export async function middleware(request: Request) {
export const config = {
matcher: [
'/((?!_next|.*\\..*).*)', // all pages excluding static assets
'/api/emails/:path*',
'/api/webhook/:path*',
'/api/roles/:path*',
+9
View File
@@ -0,0 +1,9 @@
import { i18n } from "./app/i18n/config"
export default {
locales: i18n.locales,
defaultLocale: i18n.defaultLocale,
localePrefix: i18n.localePrefix,
}
+10 -5
View File
@@ -1,5 +1,5 @@
import type { NextConfig } from "next";
import withPWA from 'next-pwa'
import createNextIntlPlugin from 'next-intl/plugin'
import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev';
async function setup() {
@@ -10,7 +10,9 @@ async function setup() {
setup()
const nextConfig: NextConfig = {
const withNextIntl = createNextIntlPlugin('./app/i18n/request.ts')
const nextConfig = {
images: {
remotePatterns: [
{
@@ -21,10 +23,13 @@ const nextConfig: NextConfig = {
},
};
export default withPWA({
const withPWAConfigured = withPWA({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
// @ts-expect-error "ignore the error"
})(nextConfig);
}) as any
const configWithPWA = withPWAConfigured(nextConfig as any) as any
export default withNextIntl(configWithPWA)
+1
View File
@@ -40,6 +40,7 @@
"nanoid": "^5.0.6",
"next": "^15.1.5",
"next-auth": "5.0.0-beta.25",
"next-intl": "^4.3.12",
"next-pwa": "^5.6.0",
"next-themes": "^0.2.1",
"postal-mime": "^2.3.2",
+109
View File
@@ -68,6 +68,9 @@ importers:
next-auth:
specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
next-intl:
specifier: ^4.3.12
version: 4.3.12(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.7.2)
next-pwa:
specifier: ^5.6.0
version: 5.6.0(@babel/core@7.26.0)(esbuild@0.17.19)(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(webpack@5.97.1(esbuild@0.17.19))
@@ -1382,6 +1385,24 @@ packages:
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
'@formatjs/fast-memoize@2.2.7':
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
'@formatjs/icu-messageformat-parser@2.11.4':
resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==}
'@formatjs/icu-skeleton-parser@1.8.16':
resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==}
'@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
'@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
@@ -2325,6 +2346,9 @@ packages:
'@rushstack/eslint-patch@1.10.4':
resolution: {integrity: sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@sinclair/typebox@0.25.24':
resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==}
@@ -3072,6 +3096,9 @@ packages:
supports-color:
optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -4048,6 +4075,9 @@ packages:
resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==}
engines: {node: '>= 0.4'}
intl-messageformat@10.7.18:
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@@ -4486,6 +4516,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -4505,6 +4539,16 @@ packages:
nodemailer:
optional: true
next-intl@4.3.12:
resolution: {integrity: sha512-yAmrQ3yx0zpNva/knniDvam3jT2d01Lv2aRgRxUIDL9zm9O4AsDjWbDIxX13t5RNf0KVnKkxH+iRcqEAmWecPg==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
next-pwa@5.6.0:
resolution: {integrity: sha512-XV8g8C6B7UmViXU8askMEYhWwQ4qc/XqJGnexbLV68hzKaGHZDMtHsm2TNxFcbR7+ypVuth/wwpiIlMwpRJJ5A==}
peerDependencies:
@@ -5598,6 +5642,11 @@ packages:
'@types/react':
optional: true
use-intl@4.3.12:
resolution: {integrity: sha512-RxW2/D17irlDOJOzClKl+kWA7ReGLpo/A8f/LF7w1kIxO6mPKVh422JJ/pDCcvtYFCI4aPtn1AXUfELKbM+7tg==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
use-sidecar@1.1.2:
resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
engines: {node: '>=10'}
@@ -6953,6 +7002,36 @@ snapshots:
'@floating-ui/utils@0.2.8': {}
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize@2.2.7':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@2.11.4':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/icu-skeleton-parser': 1.8.16
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@1.8.16':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
tslib: 2.8.1
'@formatjs/intl-localematcher@0.5.10':
dependencies:
tslib: 2.8.1
'@formatjs/intl-localematcher@0.6.2':
dependencies:
tslib: 2.8.1
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -7803,6 +7882,8 @@ snapshots:
'@rushstack/eslint-patch@1.10.4': {}
'@schummar/icu-type-parser@1.21.5': {}
'@sinclair/typebox@0.25.24': {}
'@surma/rollup-plugin-off-main-thread@2.2.3':
@@ -8715,6 +8796,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
@@ -9813,6 +9896,13 @@ snapshots:
hasown: 2.0.2
side-channel: 1.0.6
intl-messageformat@10.7.18:
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.4
tslib: 2.8.1
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
@@ -10219,6 +10309,8 @@ snapshots:
natural-compare@1.4.0: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {}
next-auth@5.0.0-beta.25(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
@@ -10227,6 +10319,16 @@ snapshots:
next: 15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
next-intl@4.3.12(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.7.2):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
negotiator: 1.0.0
next: 15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
use-intl: 4.3.12(react@19.0.0)
optionalDependencies:
typescript: 5.7.2
next-pwa@5.6.0(@babel/core@7.26.0)(esbuild@0.17.19)(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(webpack@5.97.1(esbuild@0.17.19)):
dependencies:
babel-loader: 8.4.1(@babel/core@7.26.0)(webpack@5.97.1(esbuild@0.17.19))
@@ -11370,6 +11472,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.0
use-intl@4.3.12(react@19.0.0):
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@schummar/icu-type-parser': 1.21.5
intl-messageformat: 10.7.18
react: 19.0.0
use-sidecar@1.1.2(@types/react@19.0.0)(react@19.0.0):
dependencies:
detect-node-es: 1.1.0