From 6a3db4e620f0788b504e26469d73d115e84b965d Mon Sep 17 00:00:00 2001 From: asgerzhou Date: Mon, 27 Jul 2026 20:18:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E6=8E=A5=E5=85=A5=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E6=9C=8D=E5=8A=A1=E5=B9=B6=E7=AE=80=E5=8C=96=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 nodemailer 依赖及 SMTP 邮件发送模块 - 替换 magic link 控制台日志为真实邮件发送 - 移除仪表盘页面,管理后台默认跳转至文件管理 - 精简侧边栏导航,移除用户管理与设置入口 - 移除登录页姓名字段,品牌更名为“鼎兴” - 关闭 biome 自动组织导入 --- biome.json | 2 +- package.json | 2 + pnpm-lock.yaml | 19 +++ src/app/(admin)/admin/page.tsx | 204 +----------------------- src/app/(admin)/admin/settings/page.tsx | 86 ---------- src/app/(admin)/admin/users/page.tsx | 163 ------------------- src/app/(admin)/layout.tsx | 7 +- src/app/(auth)/login/page.tsx | 16 +- src/app/layout.tsx | 4 +- src/components/app-sidebar.tsx | 101 ++---------- src/components/site-header.tsx | 2 +- src/components/user-actions.tsx | 108 ------------- src/lib/auth.ts | 11 +- src/lib/mail.ts | 85 ++++++++++ 14 files changed, 139 insertions(+), 671 deletions(-) delete mode 100644 src/app/(admin)/admin/settings/page.tsx delete mode 100644 src/app/(admin)/admin/users/page.tsx delete mode 100644 src/components/user-actions.tsx create mode 100644 src/lib/mail.ts diff --git a/biome.json b/biome.json index 41b3b95..4e5da7a 100644 --- a/biome.json +++ b/biome.json @@ -30,7 +30,7 @@ "assist": { "actions": { "source": { - "organizeImports": "on" + "organizeImports": "off" } } } diff --git a/package.json b/package.json index 783c7f0..e09ce5b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "drizzle-orm": "1.0.0-rc.4", "lucide-react": "^1.24.0", "next": "16.2.10", + "nodemailer": "^9.0.3", "pg": "^8.22.0", "proxy-agent": "^8.0.2", "react": "19.2.4", @@ -32,6 +33,7 @@ "@tailwindcss/postcss": "^4", "@types/ali-oss": "^6.23.3", "@types/node": "^20", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8de2cae..d6bb6c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: next: specifier: 16.2.10 version: 16.2.10(@babel/core@7.29.7)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nodemailer: + specifier: ^9.0.3 + version: 9.0.3 pg: specifier: ^8.22.0 version: 8.22.0 @@ -69,6 +72,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.43 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -1118,6 +1124,9 @@ packages: '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -2339,6 +2348,10 @@ packages: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -3786,6 +3799,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 20.19.43 + '@types/pg@8.20.0': dependencies: '@types/node': 20.19.43 @@ -4778,6 +4795,8 @@ snapshots: node-releases@2.0.51: {} + nodemailer@9.0.3: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index 09d5b01..2d2480d 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -1,203 +1,5 @@ -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Activity, - DollarSign, - Users, - TrendingUp, - ArrowUpRight, - ArrowDownRight, -} from "lucide-react"; +import { redirect } from "next/navigation"; -const stats = [ - { - title: "总收入", - value: "¥ 128,430", - change: "+12.5%", - trend: "up" as const, - icon: DollarSign, - desc: "较上月", - }, - { - title: "活跃用户", - value: "2,840", - change: "+8.2%", - trend: "up" as const, - icon: Users, - desc: "较上周", - }, - { - title: "转化率", - value: "3.6%", - change: "-0.4%", - trend: "down" as const, - icon: TrendingUp, - desc: "较上月", - }, - { - title: "实时会话", - value: "147", - change: "+23", - trend: "up" as const, - icon: Activity, - desc: "当前在线", - }, -]; - -const recentOrders = [ - { - id: "ORD-7821", - customer: "张三", - email: "zhangsan@example.com", - amount: "¥ 1,299", - status: "已完成", - }, - { - id: "ORD-7820", - customer: "李四", - email: "lisi@example.com", - amount: "¥ 899", - status: "处理中", - }, - { - id: "ORD-7819", - customer: "王五", - email: "wangwu@example.com", - amount: "¥ 2,499", - status: "已完成", - }, - { - id: "ORD-7818", - customer: "赵六", - email: "zhaoliu@example.com", - amount: "¥ 599", - status: "已取消", - }, -]; - -function statusVariant(status: string) { - if (status === "已完成") return "default" as const; - if (status === "处理中") return "secondary" as const; - return "destructive" as const; -} - -export default function AdminDashboardPage() { - return ( -
-
-

仪表盘

-

- 欢迎回来,这是你的管理后台概览。 -

-
- -
- {stats.map((stat) => ( - - -
- {stat.title} - -
- - {stat.value} - -
- - - {stat.trend === "up" ? ( - - ) : ( - - )} - {stat.change} - - {stat.desc} - -
- ))} -
- -
- - - 最近订单 - 最近 5 笔订单记录 - - - - - -
- {recentOrders.map((order) => ( -
-
- - {order.customer} - - - {order.email} - -
-
- - {order.status} - - - {order.amount} - -
-
- ))} -
-
-
- - - - 快捷操作 - 常用管理入口 - - - - - - - - -
-
- ); +export default function AdminIndexPage() { + redirect("/admin/uploads"); } diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx deleted file mode 100644 index 7e4b70f..0000000 --- a/src/app/(admin)/admin/settings/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; - -export default function SettingsPage() { - return ( -
-
-

设置

-

- 管理系统偏好与账户信息。 -

-
- - - - 站点信息 - 配置站点名称与基础信息。 - - -
- - -
-
- - -
-
- - - - -
- - - - 通知设置 - 配置邮件与系统通知偏好。 - - -
- - -
-
- - - - -
- - - - - - 危险操作 - 以下操作不可撤销,请谨慎处理。 - - - - - -
- ); -} diff --git a/src/app/(admin)/admin/users/page.tsx b/src/app/(admin)/admin/users/page.tsx deleted file mode 100644 index 9439421..0000000 --- a/src/app/(admin)/admin/users/page.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { headers } from "next/headers"; - -import { auth } from "@/lib/auth"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@/components/ui/avatar"; -import { UserActions } from "@/components/user-actions"; -import { Plus, Search, Filter } from "lucide-react"; - -export default async function UsersPage() { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const result = await auth.api.listUsers({ - headers: await headers(), - query: { - limit: 100, - }, - }); - - const users = result?.users ?? []; - const total = result?.total ?? 0; - - return ( -
-
-

用户管理

-

- 管理系统中的用户、角色和权限。 -

-
- - - - 用户列表 - 共 {total} 位用户 - - - - - -
-
- - -
- -
- -
- - - - 用户 - 邮箱 - 角色 - 状态 - - - - - {users.length === 0 ? ( - - - 暂无用户数据 - - - ) : ( - users.map((user) => ( - - -
- - - - {user.name.charAt(0)} - - -
- {user.name} - {user.id === session?.user.id && ( - - (你) - - )} -
-
-
- - {user.email} - - - - {user.role === "admin" ? "管理员" : "普通用户"} - - - - {user.banned ? ( - 已封禁 - ) : ( - 正常 - )} - - - - -
- )) - )} -
-
-
-
-
-
- ); -} diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index ce632ee..988038b 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -1,11 +1,10 @@ -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; - -import { auth } from "@/lib/auth"; import { AppSidebar } from "@/components/app-sidebar"; import { SiteHeader } from "@/components/site-header"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; export default async function AdminLayout({ children, diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 6740357..c20cf0e 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -18,7 +18,6 @@ import { authClient } from "@/lib/auth-client"; export default function LoginPage() { const [email, setEmail] = useState(""); - const [name, setName] = useState(""); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); const [error, setError] = useState(null); @@ -30,8 +29,7 @@ export default function LoginPage() { const { error } = await authClient.signIn.magicLink({ email, - name: name || undefined, - callbackURL: "/admin", + callbackURL: "/admin/uploads", }); setLoading(false); @@ -70,7 +68,6 @@ export default function LoginPage() { onClick={() => { setSent(false); setEmail(""); - setName(""); }} > @@ -96,17 +93,6 @@ export default function LoginPage() {
-
- - setName(e.target.value)} - autoComplete="name" - /> -
; - items?: { title: string; url: string }[]; -}; - -const navData: NavItem[] = [ - { title: "仪表盘", url: "/admin", icon: LayoutDashboard }, - { - title: "用户管理", - url: "/admin/users", - icon: Users, - items: [ - { title: "用户列表", url: "/admin/users" }, - { title: "角色管理", url: "/admin/users/roles" }, - ], - }, +const navData = [ { title: "文件管理", url: "/admin/uploads", icon: CloudUpload }, - { title: "设置", url: "/admin/settings", icon: Settings }, ]; type SidebarUser = { @@ -83,8 +49,7 @@ export function AppSidebar({ const pathname = usePathname(); const router = useRouter(); - const isActive = (url: string) => - url === "/admin" ? pathname === url : pathname.startsWith(url); + const isActive = (url: string) => pathname.startsWith(url); return ( @@ -93,14 +58,14 @@ export function AppSidebar({ } + tooltip="鼎兴管理后台" + render={} >
DX
- 鼎星管理后台 + 鼎兴管理后台 Admin Console @@ -114,50 +79,18 @@ export function AppSidebar({ 管理 - {navData.map((item) => - item.items ? ( - isActive(sub.url))} - className="group/collapsible [&[data-open]>li>button>svg:last-child]:rotate-90" + {navData.map((item) => ( + + } > - - } - > - - {item.title} - - - - - {item.items.map((sub) => ( - - } - isActive={isActive(sub.url)} - > - {sub.title} - - - ))} - - - - - ) : ( - - } - > - - {item.title} - - - ) - )} + + {item.title} + + + ))} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 7ba00c4..0552dcb 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -43,7 +43,7 @@ function deriveBreadcrumbs(pathname: string) { const crumbs = segments.map((seg, i) => ({ title: seg === "admin" - ? "首页" + ? "文件管理" : seg.charAt(0).toUpperCase() + seg.slice(1), url: "/" + segments.slice(0, i + 1).join("/"), })); diff --git a/src/components/user-actions.tsx b/src/components/user-actions.tsx deleted file mode 100644 index dedc153..0000000 --- a/src/components/user-actions.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; -import { authClient } from "@/lib/auth-client"; -import { MoreHorizontal, ShieldCheck, User, Ban, CircleCheck, Loader2 } from "lucide-react"; - -type UserRow = { - id: string; - name: string; - email: string; - role: string; - banned: boolean; -}; - -export function UserActions({ user }: { user: UserRow }) { - const router = useRouter(); - const [loading, setLoading] = useState(false); - - async function setRole(role: "admin" | "user") { - setLoading(true); - const { error } = await authClient.admin.setRole({ - userId: user.id, - role, - }); - setLoading(false); - if (error) { - console.error("设置角色失败:", error.message); - return; - } - router.refresh(); - } - - async function toggleBan() { - setLoading(true); - if (user.banned) { - const { error } = await authClient.admin.unbanUser({ userId: user.id }); - setLoading(false); - if (error) { - console.error("解封失败:", error.message); - return; - } - } else { - const { error } = await authClient.admin.banUser({ - userId: user.id, - banReason: "管理员手动封禁", - }); - setLoading(false); - if (error) { - console.error("封禁失败:", error.message); - return; - } - } - router.refresh(); - } - - return ( - - - } - > - {loading ? ( - - ) : ( - - )} - 操作菜单 - - - 设置角色 - setRole("admin")}> - - 管理员 - - setRole("user")}> - - 普通用户 - - - - {user.banned ? ( - <> - - 解除封禁 - - ) : ( - <> - - 封禁用户 - - )} - - - - ); -} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4ced8b7..0950d59 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,10 +1,10 @@ import { db } from "@/db"; import * as schema from "@/db/schema"; +import { sendMagicLinkEmail } from "@/lib/mail"; +import { ac, adminRole, userRole } from "@/lib/permissions"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { admin } from "better-auth/plugins"; -import { magicLink } from "better-auth/plugins"; -import { ac, adminRole, userRole } from "@/lib/permissions"; +import { admin, magicLink } from "better-auth/plugins"; export const auth = betterAuth({ database: drizzleAdapter(db, { @@ -27,9 +27,8 @@ export const auth = betterAuth({ }, }), magicLink({ - sendMagicLink: async ({ email, url, token }, ctx) => { - // TODO: 接入邮件服务(如 Resend)后替换为真实发信 - console.log(`[Magic Link] 发送至 ${email},链接: ${url}`); + sendMagicLink: async ({ email, url }) => { + await sendMagicLinkEmail(email, url); }, expiresIn: 60 * 5, // 5 分钟过期 }), diff --git a/src/lib/mail.ts b/src/lib/mail.ts new file mode 100644 index 0000000..b1c9ea6 --- /dev/null +++ b/src/lib/mail.ts @@ -0,0 +1,85 @@ +import nodemailer from "nodemailer"; + +// 复用 transporter 实例,避免每次发信都重新创建连接 +let transporter: nodemailer.Transporter | null = null; + +function getTransporter() { + if (transporter) return transporter; + + transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT ?? 465), + secure: (Number(process.env.SMTP_PORT ?? 465) === 465), + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + + return transporter; +} + +export async function sendMagicLinkEmail(email: string, url: string) { + const fromName = process.env.SMTP_FROM_NAME ?? "鼎兴"; + const fromAddress = process.env.SMTP_FROM_ADDRESS ?? "no-reply@dingxing.com"; + + const transport = getTransporter(); + + await transport.sendMail({ + from: `"${fromName}" <${fromAddress}>`, + to: email, + subject: "【鼎兴】登录链接", + html: magicLinkTemplate(url), + }); +} + +function magicLinkTemplate(url: string): string { + return ` + + + + + + + +
+ + + + + + + +
+ 鼎兴 +
+

登录到鼎兴管理后台

+

+ 你收到这封邮件是因为有人请求使用此邮箱登录鼎兴。点击下方按钮即可完成登录: +

+ + + + +
+ + 登录 + +
+

+ 此链接将在 5 分钟后失效。如果你没有发起此请求,请忽略此邮件。 +

+
+

+ 或者复制此链接到浏览器:
${url} +

+
+

+ © ${new Date().getFullYear()} 鼎兴. 保留所有权利. +

+
+ + + `.trim(); +}