mirror of
https://github.com/beilunyang/moemail.git
synced 2026-07-07 07:11:37 +08:00
feat: Init
This commit is contained in:
150
app/components/emails/create-dialog.tsx
Normal file
150
app/components/emails/create-dialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Plus, RefreshCw } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { nanoid } from "nanoid"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
interface CreateDialogProps {
|
||||
onEmailCreated: () => void
|
||||
}
|
||||
|
||||
export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [emailName, setEmailName] = useState("")
|
||||
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString()) // Default to 24 hours
|
||||
const { toast } = useToast()
|
||||
|
||||
const generateRandomName = () => setEmailName(nanoid(8))
|
||||
|
||||
const createEmail = async () => {
|
||||
if (!emailName.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入邮箱名",
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch("/api/emails/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: emailName,
|
||||
expiryTime: parseInt(expiryTime) // 确保转换为数字
|
||||
})
|
||||
})
|
||||
|
||||
if (response.status === 409) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "该邮箱名已被使用",
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "已达到最大邮箱数量限制",
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error("Failed to create email")
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "已创建新的临时邮箱"
|
||||
})
|
||||
onEmailCreated()
|
||||
setOpen(false)
|
||||
setEmailName("")
|
||||
} catch {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "创建邮箱失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
创建新邮箱
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新的临时邮箱</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={emailName}
|
||||
onChange={(e) => setEmailName(e.target.value)}
|
||||
placeholder="输入邮箱名"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={generateRandomName}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Label className="shrink-0 text-muted-foreground">过期时间</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>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
完整邮箱地址将为: {emailName ? `${emailName}@${EMAIL_CONFIG.DOMAIN}` : "..."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={createEmail} disabled={loading}>
|
||||
{loading ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
162
app/components/emails/email-list.tsx
Normal file
162
app/components/emails/email-list.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { CreateDialog } from "./create-dialog"
|
||||
import { Mail, RefreshCw } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useThrottle } from "@/hooks/use-throttle"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
interface Email {
|
||||
id: string
|
||||
address: string
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
interface EmailListProps {
|
||||
onEmailSelect: (email: Email) => void
|
||||
selectedEmailId?: string
|
||||
}
|
||||
|
||||
interface EmailResponse {
|
||||
emails: Email[]
|
||||
nextCursor: string | null
|
||||
total: number
|
||||
}
|
||||
|
||||
export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
const { data: session } = useSession()
|
||||
const [emails, setEmails] = useState<Email[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const fetchEmails = async (cursor?: string) => {
|
||||
try {
|
||||
const url = new URL("/api/emails", window.location.origin)
|
||||
if (cursor) {
|
||||
url.searchParams.set('cursor', cursor)
|
||||
}
|
||||
const response = await fetch(url)
|
||||
const data = await response.json() as EmailResponse
|
||||
|
||||
if (!cursor) {
|
||||
const newEmails = data.emails
|
||||
const oldEmails = emails
|
||||
|
||||
const lastDuplicateIndex = newEmails.findIndex(
|
||||
newEmail => oldEmails.some(oldEmail => oldEmail.id === newEmail.id)
|
||||
)
|
||||
|
||||
if (lastDuplicateIndex === -1) {
|
||||
setEmails(newEmails)
|
||||
setNextCursor(data.nextCursor)
|
||||
setTotal(data.total)
|
||||
return
|
||||
}
|
||||
const uniqueNewEmails = newEmails.slice(0, lastDuplicateIndex)
|
||||
setEmails([...uniqueNewEmails, ...oldEmails])
|
||||
setTotal(data.total)
|
||||
return
|
||||
}
|
||||
setEmails(prev => [...prev, ...data.emails])
|
||||
setNextCursor(data.nextCursor)
|
||||
setTotal(data.total)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch emails:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await fetchEmails()
|
||||
}
|
||||
|
||||
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (loadingMore) return
|
||||
|
||||
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
|
||||
const threshold = clientHeight * 1.5
|
||||
const remainingScroll = scrollHeight - scrollTop
|
||||
|
||||
if (remainingScroll <= threshold && nextCursor) {
|
||||
setLoadingMore(true)
|
||||
fetchEmails(nextCursor)
|
||||
}
|
||||
}, 200)
|
||||
|
||||
useEffect(() => {
|
||||
if (session) fetchEmails()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session])
|
||||
|
||||
if (!session) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-2 flex justify-between items-center border-b border-primary/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className={cn("h-8 w-8", refreshing && "animate-spin")}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱
|
||||
</span>
|
||||
</div>
|
||||
<CreateDialog onEmailCreated={handleRefresh} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-2" onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="text-center text-sm text-gray-500">加载中...</div>
|
||||
) : emails.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{emails.map(email => (
|
||||
<div
|
||||
key={email.id}
|
||||
onClick={() => onEmailSelect(email)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded cursor-pointer text-sm",
|
||||
"hover:bg-primary/5",
|
||||
selectedEmailId === email.id && "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<Mail className="w-4 h-4 text-primary/60" />
|
||||
<div className="truncate flex-1">
|
||||
<div className="font-medium truncate">{email.address}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
过期时间: {new Date(email.expiresAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="text-center text-sm text-gray-500 py-2">
|
||||
加载更多...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
还没有邮箱,创建一个吧!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
app/components/emails/message-list.tsx
Normal file
196
app/components/emails/message-list.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Mail, Calendar, RefreshCw } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useThrottle } from "@/hooks/use-throttle"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
from_address: string
|
||||
subject: string
|
||||
received_at: number
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
email: {
|
||||
id: string
|
||||
address: string
|
||||
}
|
||||
onMessageSelect: (messageId: string) => void
|
||||
selectedMessageId?: string | null
|
||||
}
|
||||
|
||||
interface MessageResponse {
|
||||
messages: Message[]
|
||||
nextCursor: string | null
|
||||
total: number
|
||||
}
|
||||
|
||||
export function MessageList({ email, onMessageSelect, selectedMessageId }: MessageListProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const pollTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
// 当 messages 改变时更新 ref
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages
|
||||
}, [messages])
|
||||
|
||||
const fetchMessages = async (cursor?: string) => {
|
||||
try {
|
||||
const url = new URL(`/api/emails/${email.id}`, window.location.origin)
|
||||
if (cursor) {
|
||||
url.searchParams.set('cursor', cursor)
|
||||
}
|
||||
const response = await fetch(url)
|
||||
const data = await response.json() as MessageResponse
|
||||
|
||||
if (!cursor) {
|
||||
const newMessages = data.messages
|
||||
const oldMessages = messagesRef.current
|
||||
|
||||
const lastDuplicateIndex = newMessages.findIndex(
|
||||
newMsg => oldMessages.some(oldMsg => oldMsg.id === newMsg.id)
|
||||
)
|
||||
|
||||
if (lastDuplicateIndex === -1) {
|
||||
setMessages(newMessages)
|
||||
setNextCursor(data.nextCursor)
|
||||
setTotal(data.total)
|
||||
return
|
||||
}
|
||||
const uniqueNewMessages = newMessages.slice(0, lastDuplicateIndex)
|
||||
setMessages([...uniqueNewMessages, ...oldMessages])
|
||||
setTotal(data.total)
|
||||
return
|
||||
}
|
||||
setMessages(prev => [...prev, ...data.messages])
|
||||
setNextCursor(data.nextCursor)
|
||||
setTotal(data.total)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch messages:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling() // 先清除之前的轮询
|
||||
pollTimeoutRef.current = setInterval(() => {
|
||||
if (!refreshing && !loadingMore) {
|
||||
fetchMessages()
|
||||
}
|
||||
}, EMAIL_CONFIG.POLL_INTERVAL)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimeoutRef.current) {
|
||||
clearInterval(pollTimeoutRef.current)
|
||||
pollTimeoutRef.current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await fetchMessages()
|
||||
}
|
||||
|
||||
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (loadingMore) return
|
||||
|
||||
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
|
||||
const threshold = clientHeight * 1.5
|
||||
const remainingScroll = scrollHeight - scrollTop
|
||||
|
||||
if (remainingScroll <= threshold && nextCursor) {
|
||||
setLoadingMore(true)
|
||||
fetchMessages(nextCursor)
|
||||
}
|
||||
}, 200)
|
||||
|
||||
useEffect(() => {
|
||||
if (!email.id) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setNextCursor(null)
|
||||
fetchMessages()
|
||||
startPolling()
|
||||
|
||||
return () => {
|
||||
stopPolling()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email.id])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-2 flex justify-between items-center border-b border-primary/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className={cn("h-8 w-8", refreshing && "animate-spin")}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{total > 0 ? `${total} 封邮件` : "暂无邮件"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">加载中...</div>
|
||||
) : messages.length > 0 ? (
|
||||
<div className="divide-y divide-primary/10">
|
||||
{messages.map(message => (
|
||||
<div
|
||||
key={message.id}
|
||||
onClick={() => onMessageSelect(message.id)}
|
||||
className={cn(
|
||||
"p-3 hover:bg-primary/5 cursor-pointer",
|
||||
selectedMessageId === message.id && "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className="w-4 h-4 text-primary/60 mt-1" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{message.subject}</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="truncate">{message.from_address}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(message.received_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="text-center text-sm text-gray-500 py-2">
|
||||
加载更多...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
暂无邮件
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
app/components/emails/message-view.tsx
Normal file
208
app/components/emails/message-view.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
from_address: string
|
||||
subject: string
|
||||
content: string
|
||||
html: string | null
|
||||
received_at: number
|
||||
}
|
||||
|
||||
interface MessageViewProps {
|
||||
emailId: string
|
||||
messageId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ViewMode = "html" | "text"
|
||||
|
||||
export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
const [message, setMessage] = useState<Message | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("html")
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMessage = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/emails/${emailId}/${messageId}`)
|
||||
const data = await response.json() as { message: Message }
|
||||
setMessage(data.message)
|
||||
if (!data.message.html) {
|
||||
setViewMode("text")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch message:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchMessage()
|
||||
}, [emailId, messageId])
|
||||
|
||||
// 处理 iframe 内容
|
||||
useEffect(() => {
|
||||
if (viewMode === "html" && message?.html && iframeRef.current) {
|
||||
const iframe = iframeRef.current
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document
|
||||
|
||||
if (doc) {
|
||||
doc.open()
|
||||
doc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<base target="_blank">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: ${document.documentElement.classList.contains('dark') ? '#fff' : '#000'};
|
||||
background: transparent;
|
||||
}
|
||||
body {
|
||||
padding: 20px;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
a {
|
||||
color: #2563eb;
|
||||
}
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: ${document.documentElement.classList.contains('dark')
|
||||
? 'rgba(130, 109, 217, 0.3)'
|
||||
: 'rgba(130, 109, 217, 0.2)'};
|
||||
border-radius: 9999px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: ${document.documentElement.classList.contains('dark')
|
||||
? 'rgba(130, 109, 217, 0.5)'
|
||||
: 'rgba(130, 109, 217, 0.4)'};
|
||||
}
|
||||
/* Firefox 滚动条 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${document.documentElement.classList.contains('dark')
|
||||
? 'rgba(130, 109, 217, 0.3) transparent'
|
||||
: 'rgba(130, 109, 217, 0.2) transparent'};
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${message.html}</body>
|
||||
</html>
|
||||
`)
|
||||
doc.close()
|
||||
|
||||
// 更新高度以填充容器
|
||||
const updateHeight = () => {
|
||||
const container = iframe.parentElement
|
||||
if (container) {
|
||||
iframe.style.height = `${container.clientHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
updateHeight()
|
||||
window.addEventListener('resize', updateHeight)
|
||||
|
||||
// 监听内容变化
|
||||
const resizeObserver = new ResizeObserver(updateHeight)
|
||||
resizeObserver.observe(doc.body)
|
||||
|
||||
// 监听图片加载
|
||||
doc.querySelectorAll('img').forEach((img: HTMLImageElement) => {
|
||||
img.onload = updateHeight
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateHeight)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [message?.html, viewMode])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 space-y-3 border-b border-primary/20">
|
||||
<h3 className="text-base font-bold">{message.subject}</h3>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>发件人:{message.from_address}</p>
|
||||
<p>时间:{new Date(message.received_at).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.html && (
|
||||
<div className="border-b border-primary/20 p-2">
|
||||
<RadioGroup
|
||||
value={viewMode}
|
||||
onValueChange={(value) => setViewMode(value as ViewMode)}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="html" id="html" />
|
||||
<Label
|
||||
htmlFor="html"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
HTML 格式
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="text" id="text" />
|
||||
<Label
|
||||
htmlFor="text"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
纯文本格式
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{viewMode === "html" && message.html ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="absolute inset-0 w-full h-full border-0 bg-transparent"
|
||||
sandbox="allow-same-origin allow-popups"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-sm whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
app/components/emails/three-column-layout.tsx
Normal file
154
app/components/emails/three-column-layout.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { EmailList } from "./email-list"
|
||||
import { MessageList } from "./message-list"
|
||||
import { MessageView } from "./message-view"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Email {
|
||||
id: string
|
||||
address: string
|
||||
}
|
||||
|
||||
export function ThreeColumnLayout() {
|
||||
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null)
|
||||
|
||||
const columnClass = "border-2 border-primary/20 bg-background rounded-lg overflow-hidden flex flex-col"
|
||||
const headerClass = "p-2 border-b-2 border-primary/20 flex items-center justify-between shrink-0"
|
||||
const titleClass = "text-sm font-bold px-2"
|
||||
|
||||
// 移动端视图逻辑
|
||||
const getMobileView = () => {
|
||||
if (selectedMessageId) return "message"
|
||||
if (selectedEmail) return "emails"
|
||||
return "list"
|
||||
}
|
||||
|
||||
const mobileView = getMobileView()
|
||||
|
||||
return (
|
||||
<div className="pb-5 pt-20 h-full flex flex-col">
|
||||
{/* 桌面端三栏布局 */}
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailList
|
||||
onEmailSelect={setSelectedEmail}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("col-span-4", columnClass)}>
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>
|
||||
{selectedEmail ? (
|
||||
<span className="truncate block">{selectedEmail.address}</span>
|
||||
) : (
|
||||
"选择邮箱查看消息"
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{selectedEmail && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageList
|
||||
email={selectedEmail}
|
||||
onMessageSelect={setSelectedMessageId}
|
||||
selectedMessageId={selectedMessageId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn("col-span-5", columnClass)}>
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>
|
||||
{selectedMessageId ? "邮件内容" : "选择邮件查看详情"}
|
||||
</h2>
|
||||
</div>
|
||||
{selectedEmail && selectedMessageId && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageView
|
||||
emailId={selectedEmail.id}
|
||||
messageId={selectedMessageId}
|
||||
onClose={() => setSelectedMessageId(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端单栏布局 */}
|
||||
<div className="lg:hidden h-full min-h-0">
|
||||
<div className={cn("h-full", columnClass)}>
|
||||
{mobileView === "list" && (
|
||||
<>
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>我的邮箱</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailList
|
||||
onEmailSelect={(email) => {
|
||||
setSelectedEmail(email)
|
||||
}}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mobileView === "emails" && selectedEmail && (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className={headerClass}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedEmail(null)
|
||||
}}
|
||||
className="text-sm text-primary"
|
||||
>
|
||||
← 返回邮箱列表
|
||||
</button>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{selectedEmail.address}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageList
|
||||
email={selectedEmail}
|
||||
onMessageSelect={setSelectedMessageId}
|
||||
selectedMessageId={selectedMessageId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileView === "message" && selectedEmail && selectedMessageId && (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className={headerClass}>
|
||||
<button
|
||||
onClick={() => setSelectedMessageId(null)}
|
||||
className="text-sm text-primary"
|
||||
>
|
||||
← 返回消息列表
|
||||
</button>
|
||||
<span className="text-sm font-medium">邮件内容</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageView
|
||||
emailId={selectedEmail.id}
|
||||
messageId={selectedMessageId}
|
||||
onClose={() => setSelectedMessageId(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user