mirror of
https://github.com/beilunyang/moemail.git
synced 2026-05-07 04:32:44 +08:00
feat: add internationalization support with next-intl
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
62
app/components/layout/language-switcher.tsx
Normal file
62
app/components/layout/language-switcher.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { i18n } from "@/i18n/config"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Languages } from "lucide-react"
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const t = useTranslations("common")
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const switchLocale = (newLocale: string) => {
|
||||
if (newLocale === locale) return
|
||||
|
||||
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
|
||||
|
||||
const segments = pathname.split("/")
|
||||
if (i18n.locales.includes(segments[1] as any)) {
|
||||
segments[1] = newLocale
|
||||
} else {
|
||||
segments.splice(1, 0, newLocale)
|
||||
}
|
||||
const newPath = segments.join("/")
|
||||
|
||||
router.push(newPath)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Languages className="h-5 w-5" />
|
||||
<span className="sr-only">{t("lang.switch")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => switchLocale("en")}
|
||||
className={locale === "en" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("lang.en")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => switchLocale("zh-CN")}
|
||||
className={locale === "zh-CN" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("lang.zhCN")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
点击上方的创建 "API Key" 按钮来创建你的第一个 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 是邮箱的有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user