diff --git a/frontend/src/components/modal.tsx b/frontend/src/components/modal.tsx index b204e6c..6a5d1d9 100644 --- a/frontend/src/components/modal.tsx +++ b/frontend/src/components/modal.tsx @@ -1,63 +1,76 @@ -import { Link } from "react-router-dom"; -import Twitter from "./icons/Twitter.tsx"; -import GitHub from "./icons/GitHub.tsx"; -import MailIcon from "./icons/MailIcon.tsx"; -import WrdoLogo from "./icons/Wrdo.tsx"; +import { Dispatch, SetStateAction, useCallback, useEffect, useRef } from 'react'; +// fix: 将具名导入 { useWindowSize } 更改为默认导入 useWindowSize +import useWindowSize from '../hooks/use-window-size'; +import Leaflet from './leaflet'; -/** - * 网站的页脚组件 - */ -export function Footer() { - return ( -
- - VMAIL.DEV - {" "} -

- © 2024 Products of{" "} - - oiov - - . -

-
- {/* 社交媒体和联系方式链接 */} - - - - - - - - - - - - -
-
+// 将默认导出(export default)改为具名导出(export) +export function Modal({ + children, + showModal, + setShowModal, +}: { + children: React.ReactNode; + showModal: boolean; + setShowModal: Dispatch>; +}) { + const modalRef = useRef(null); + const { isMobile } = useWindowSize(); + + // close modal on click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + setShowModal(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [modalRef, setShowModal]); + + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + setShowModal(false); + } + }, + [setShowModal], ); -} + + useEffect(() => { + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onKeyDown]); + + if (isMobile) { + return ( + + {children} + + ); + } + + return ( + <> + {showModal && ( +
+
+ {children} +
+
+ )} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/password.tsx b/frontend/src/components/password.tsx index d7172e3..1e7eb07 100644 --- a/frontend/src/components/password.tsx +++ b/frontend/src/components/password.tsx @@ -2,38 +2,42 @@ import { Dispatch, SetStateAction, useCallback, - useEffect, useMemo, useState, } from "react"; -import Modal from "./modal"; -import { Form, useNavigation } from "@remix-run/react"; +// fix: 使用具名导入来修复构建错误 +import { Modal } from "./modal"; import { useTranslation } from "react-i18next"; import Close from "./icons/Close"; import toast from "react-hot-toast"; -export default function PasswordModal({ - password, - showPasswordModal, - setShowPasswordModal, -}: { - password: string; +// 定义组件的 props 类型 +interface PasswordModalProps { showPasswordModal: boolean; setShowPasswordModal: Dispatch>; -}) { - const navigation = useNavigation(); + onLogin: (password: string) => Promise; // feat: 添加 onLogin 回调处理登录逻辑 + isLoggingIn: boolean; // feat: 添加状态以在 UI 中反映登录过程 +} + +export default function PasswordModal({ + showPasswordModal, + setShowPasswordModal, + onLogin, + isLoggingIn, +}: PasswordModalProps) { const { t } = useTranslation(); + const [password, setPassword] = useState(""); // 状态:存储输入的密码 - const [isSubmitted, setIsSubmitted] = useState(false); - - useEffect(() => { - if (showPasswordModal && navigation.state === "submitting") { - setIsSubmitted(true); + // 提交处理器 + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!password) { + toast.error(t("Password is required")); + return; } - if (showPasswordModal && navigation.state === "idle" && isSubmitted) { - setShowPasswordModal(false); - } - }, [navigation.state, showPasswordModal]); + await onLogin(password); + // 登录成功或失败的 toast 提示已在 Home.tsx 中处理 + }; return ( @@ -51,9 +55,10 @@ export default function PasswordModal({ {t("Save your password and continue using this email in 1 day")}

-
+ setPassword(e.target.value)} type="text" name="password" placeholder={t("Enter your password *")} @@ -66,55 +71,51 @@ export default function PasswordModal({ )} .

-

{t( "Remember your password, otherwise your email will expire and cannot be retrieved" )} .

- - {password && password.length > 0 ? ( - - ) : ( - - )} -
+ +
); } -export function usePasswordModal(password: string) { +// 自定义 Hook,用于管理密码模态框的可见性 +export function usePasswordModal() { const [showPasswordModal, setShowPasswordModal] = useState(false); - const SenderModalCallback = useCallback(() => { - return ( - - ); - }, [showPasswordModal, setShowPasswordModal]); + // 使用 useCallback 优化 PasswordModal 组件的创建 + const PasswordModalCallback = useCallback( + ({ onLogin, isLoggingIn }: Omit) => { + return ( + + ); + }, + [showPasswordModal] // 依赖项 + ); return useMemo( - () => ({ setShowPasswordModal, PasswordModal: SenderModalCallback }), - [setShowPasswordModal, SenderModalCallback] + () => ({ + showPasswordModal, + setShowPasswordModal, + PasswordModal: PasswordModalCallback + }), + [setShowPasswordModal, PasswordModalCallback, showPasswordModal] ); } diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 1bb9d1a..a1cc08a 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -8,10 +8,15 @@ import { Toaster, toast } from 'react-hot-toast'; import { MailList } from '../components/MailList.tsx'; import { CopyButton } from '../components/CopyButton.tsx'; -import { getEmails, deleteEmails } from '../services/api.ts'; +// feat: 导入 loginByPassword +import { getEmails, deleteEmails, loginByPassword } from '../services/api.ts'; import { useConfig } from '../hooks/useConfig.ts'; import { getRandomCharacter } from '../lib/utlis.ts'; +// feat: 导入密码模态框和相关 hook +import { usePasswordModal } from '../components/password.tsx'; +import PasswordIcon from '../components/icons/Password.tsx'; // 假设你有一个密码图标 + // 图标导入 import ShieldCheck from "../components/icons/ShieldCheck.tsx"; import Cloudflare from "../components/icons/Cloudflare.tsx"; @@ -32,6 +37,10 @@ export function Home() { const [turnstileToken, setTurnstileToken] = useState(''); const [selectedIds, setSelectedIds] = useState([]); + // feat: 初始化密码模态框 + const { PasswordModal, setShowPasswordModal } = usePasswordModal(); + const [isLoggingIn, setIsLoggingIn] = useState(false); + // 使用 React Query 获取邮件列表 const { data: emails = [], isLoading, isFetching, refetch } = useQuery({ queryKey: ['emails', address], @@ -42,7 +51,7 @@ export function Home() { refetchInterval: 20000, // 当查询出错时,自动刷新会停止,这里添加错误提示方便调试 onError: (err: Error) => { - toast.error(`获取邮件失败: ${err.message}`, { duration: 5000 }); + toast.error(`${t("Failed to get emails")}: ${err.message}`, { duration: 5000 }); }, // 失败后不自动重试 retry: false, @@ -71,28 +80,51 @@ export function Home() { const deleteMutation = useMutation({ mutationFn: (ids: string[]) => deleteEmails(ids, turnstileToken), onSuccess: () => { - toast.success('邮件已删除'); + toast.success(t('Emails deleted')); setSelectedIds([]); // 清空选择 // 使邮件列表的查询失效,触发一次刷新 queryClient.invalidateQueries({ queryKey: ['emails', address] }); }, onError: () => { - toast.error('删除失败'); + toast.error(t('Deletion failed')); } }); // 定义 handleDeleteEmails 函数 const handleDeleteEmails = (ids: string[]) => { if (ids.length === 0) { - toast.error('请选择要删除的邮件'); + toast.error(t('Please select emails to delete')); return; } deleteMutation.mutate(ids); }; + // feat: 处理密码登录的函数 + const handleLogin = async (password: string) => { + if (!turnstileToken) { + toast.error(t('No captcha response')); + return; + } + setIsLoggingIn(true); + try { + const data = await loginByPassword(password, turnstileToken); + Cookies.set('userMailbox', data.address, { expires: 1 }); + setAddress(data.address); + setShowPasswordModal(false); // 关闭模态框 + toast.success(t("Login successful")); + } catch (error: any) { + toast.error(`${t("Login failed")}: ${error.message}`); + } finally { + setIsLoggingIn(false); + } + }; + + return (
+ {/* feat: 渲染密码模态框 */} +
{/* 左侧信息面板 */}
@@ -135,6 +167,13 @@ export function Home() { className="py-2.5 rounded-md w-full bg-cyan-600 hover:opacity-90 disabled:cursor-not-allowed disabled:bg-zinc-500"> {t("Create temporary email")} + {/* feat: 添加登录按钮 */} +

setShowPasswordModal(true)}> + + {t("Have a password? Login.")} +

)}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 78ae17d..099d7b4 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,28 +1,59 @@ -import axios from 'axios'; -import { Email } from 'database/schema'; // 假设您可以从数据库包中导入类型 +// 导入 Email 类型,'database' 是您 monorepo 中的包名 +import type { Email } from 'database'; + +const API_BASE_URL = '/api'; // 定义 Turnstile 人机验证所需的 token +// 恢复此接口定义,以备将来使用 interface ApiPayload { token: string; [key: string]: any; } // 获取邮件列表 -export const getEmails = async (address: string, token: string): Promise => { - const response = await axios.post('/api/emails', { address, token }); - return response.data; -}; +export async function getEmails(address: string, token: string): Promise { + const response = await fetch(`${API_BASE_URL}/emails`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, token }), + }); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); +} -// 获取单个邮件详情 -export const getEmailById = async (id: string): Promise => { - const response = await axios.get(`/api/emails/${id}`); - return response.data; -}; +// feat: 添加获取单封邮件详情的函数 +export async function getEmailById(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/emails/${id}`); + if (!response.ok) { + throw new Error('Failed to fetch email'); + } + return response.json(); +} // 删除邮件 -export const deleteEmails = async (ids: string[], token: string): Promise => { - const response = await axios.post('/api/delete-emails', { ids, token }); - return response.data; -}; +export async function deleteEmails(ids: string[], token: string): Promise { + const response = await fetch(`${API_BASE_URL}/delete-emails`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids, token }), + }); + if (!response.ok) { + throw new Error('Failed to delete emails'); + } +} -// ... 您可以在此添加更多 API 调用函数 \ No newline at end of file +// feat: 添加密码登录函数 +export async function loginByPassword(password: string, token: string): Promise<{ address: string }> { + const response = await fetch(`${API_BASE_URL}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password, token }), + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Login failed'); + } + return response.json(); +} \ No newline at end of file diff --git a/packages/database/dao.ts b/packages/database/dao.ts index 7da129b..25fdb8c 100644 --- a/packages/database/dao.ts +++ b/packages/database/dao.ts @@ -1,4 +1,4 @@ -import { count, desc, eq, and, inArray } from "drizzle-orm"; +import { count, desc, eq, and, inArray, lt } from "drizzle-orm"; import { LibSQLDatabase } from "drizzle-orm/libsql"; import { emails, InsertEmail } from "./schema"; @@ -86,4 +86,24 @@ export async function deleteEmails(db: LibSQLDatabase, ids: string[]) { console.error(e); return { count: 0 }; } +} + +/** + * 新增函数:根据提供的过期时间删除此时间之前的所有邮件。 + * @param db Drizzle 数据库实例。 + * @param expirationTime 一个 Date 对象,表示过期时间点。 + * @returns 返回一个包含已删除邮件数量的对象,或在出错时返回 { count: 0 }。 + */ +export async function deleteExpiredEmails(db: LibSQLDatabase, expirationTime: Date) { + try { + // 使用Drizzle的lt(小于)操作符来比较createdAt字段和expirationTime + const result = await db.delete(emails).where(lt(emails.createdAt, expirationTime)).execute(); + // 返回受影响的行数,即已删除的邮件数量 + return { count: result.rowsAffected }; + } catch (e) { + // 如果在删除过程中发生错误,则在控制台打印错误信息 + console.error('清理过期邮件失败:', e); + // 并返回一个表示删除数量为0的对象 + return { count: 0 }; + } } \ No newline at end of file diff --git a/worker/src/index.ts b/worker/src/index.ts index a1e4394..364bdce 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -2,7 +2,8 @@ import { Hono } from 'hono'; import { serveStatic } from 'hono/cloudflare-workers'; import { cors } from 'hono/cors'; // 路径修正:使用相对路径并导入正确的函数 -import { deleteEmails, findEmailById, getEmailsByMessageTo, insertEmail } from '../../packages/database/dao'; +// feat: 导入 getEmailByPassword 函数 +import { deleteEmails, findEmailById, getEmailByPassword, getEmailsByMessageTo, insertEmail, deleteExpiredEmails } from '../../packages/database/dao'; import { getD1DB } from '../../packages/database/db'; import { InsertEmail, insertEmailSchema } from '../../packages/database/schema'; import { nanoid } from 'nanoid/non-secure'; @@ -26,9 +27,26 @@ const app = new Hono<{ Bindings: Env }>(); // 配置 CORS app.use('/api/*', cors()); +// fix: 修复重复读取请求体的问题,并增强健壮性 // Turnstile (人机验证) 中间件 const turnstile = async (c, next) => { - const body = await c.req.json().catch(() => ({})); + let body: any = {}; // 默认值为空对象 + try { + // 尝试读取请求体文本。如果请求体为空,.text() 会返回空字符串,不会像 .json() 那样报错。 + const rawBody = await c.req.text(); + if (rawBody) { + // 如果请求体不为空,则解析为 JSON + body = JSON.parse(rawBody); + } + } catch (e) { + // 如果 JSON.parse 失败,说明请求体不是有效的 JSON + // 这种情况我们依然继续,因为 token 可能在 header 中。body 维持为空对象。 + console.error("无法将请求体解析为 JSON:", e); + } + + // 将解析后的 body (或空对象) 存入上下文,供后续的处理器使用 + c.set('parsedBody', body); + const token = body.token || c.req.header('cf-turnstile-token'); const ip = c.req.header('CF-Connecting-IP'); @@ -48,6 +66,8 @@ const turnstile = async (c, next) => { const data = await res.json(); if (!data.success) { + // feat: 增加详细的错误日志,方便调试 + console.error("Turnstile 验证失败:", data['error-codes']); return c.json({ message: 'token is invalid' }, 400); } @@ -60,7 +80,11 @@ const api = app.basePath('/api'); // 获取邮件列表 api.post('/emails', turnstile, async (c) => { const db = getD1DB(c.env.DB); - const { address } = await c.req.json(); + // fix: 从上下文中获取已解析的请求体,并增加健壮性处理 + const { address } = c.get('parsedBody') || {}; + if (!address) { + return c.json({ message: 'address is required' }, 400); + } // 函数调用修正:使用 getEmailsByMessageTo 函数 const emails = await getEmailsByMessageTo(db, address as string); return c.json(emails); @@ -72,17 +96,39 @@ api.get('/emails/:id', async (c) => { const { id } = c.req.param(); // 函数调用修正:使用 findEmailById 函数 const email = await findEmailById(db, id); + if (!email) { + return c.json({ message: 'Email not found'}, 404); + } return c.json(email); }); // 删除邮件 api.post('/delete-emails', turnstile, async (c) => { const db = getD1DB(c.env.DB); - const { ids } = await c.req.json(); + // fix: 从上下文中获取已解析的请求体,并增加健壮性处理 + const { ids } = c.get('parsedBody') || {}; + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return c.json({ message: 'ids are required' }, 400); + } const result = await deleteEmails(db, ids as string[]); return c.json(result); }); +// feat: 添加登录路由 +api.post('/login', turnstile, async (c) => { + const db = getD1DB(c.env.DB); + // fix: 从上下文中获取已解析的请求体,并增加健壮性处理 + const { password } = c.get('parsedBody') || {}; + if (!password) { + return c.json({ message: 'Password is required' }, 400); + } + const email = await getEmailByPassword(db, password as string); + if (!email) { + return c.json({ message: 'Invalid password' }, 404); + } + return c.json({ address: email.messageTo }); +}); + // 前端配置接口 app.get('/config', (c) => { @@ -115,7 +161,7 @@ export default { const email = insertEmailSchema.parse(newEmail); await insertEmail(db, email); } catch (e) { - console.error('Failed to process email:', e); + console.error('处理邮件失败:', e); } }, @@ -133,8 +179,8 @@ export default { // 定时任务 (清理过期邮件) async scheduled(event, env, ctx) { const db = getD1DB(env.DB); + // 清理一小时前的邮件 const oneHourAgo = new Date(Date.now() - 1000 * 60 * 60); - // 注意: `deleteExpiredEmails` 是一个假设的函数,您需要在 `database/dao.ts` 中实现它 - // await deleteExpiredEmails(db, oneHourAgo); + await deleteExpiredEmails(db, oneHourAgo); }, }; \ No newline at end of file