feat(mail): 接入邮件服务并简化管理后台

- 新增 nodemailer 依赖及 SMTP 邮件发送模块
- 替换 magic link 控制台日志为真实邮件发送
- 移除仪表盘页面,管理后台默认跳转至文件管理
- 精简侧边栏导航,移除用户管理与设置入口
- 移除登录页姓名字段,品牌更名为“鼎兴”
- 关闭 biome 自动组织导入
This commit is contained in:
asgerzhou
2026-07-27 20:18:33 +08:00
parent 57628c04d6
commit 6a3db4e620
14 changed files with 139 additions and 671 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
"assist": { "assist": {
"actions": { "actions": {
"source": { "source": {
"organizeImports": "on" "organizeImports": "off"
} }
} }
} }
+2
View File
@@ -19,6 +19,7 @@
"drizzle-orm": "1.0.0-rc.4", "drizzle-orm": "1.0.0-rc.4",
"lucide-react": "^1.24.0", "lucide-react": "^1.24.0",
"next": "16.2.10", "next": "16.2.10",
"nodemailer": "^9.0.3",
"pg": "^8.22.0", "pg": "^8.22.0",
"proxy-agent": "^8.0.2", "proxy-agent": "^8.0.2",
"react": "19.2.4", "react": "19.2.4",
@@ -32,6 +33,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/ali-oss": "^6.23.3", "@types/ali-oss": "^6.23.3",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
+19
View File
@@ -35,6 +35,9 @@ importers:
next: next:
specifier: 16.2.10 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) 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: pg:
specifier: ^8.22.0 specifier: ^8.22.0
version: 8.22.0 version: 8.22.0
@@ -69,6 +72,9 @@ importers:
'@types/node': '@types/node':
specifier: ^20 specifier: ^20
version: 20.19.43 version: 20.19.43
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/pg': '@types/pg':
specifier: ^8.20.0 specifier: ^8.20.0
version: 8.20.0 version: 8.20.0
@@ -1118,6 +1124,9 @@ packages:
'@types/node@20.19.43': '@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/pg@8.20.0': '@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
@@ -2339,6 +2348,10 @@ packages:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
nodemailer@9.0.3:
resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==}
engines: {node: '>=6.0.0'}
npm-run-path@4.0.1: npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3786,6 +3799,10 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 20.19.43
'@types/pg@8.20.0': '@types/pg@8.20.0':
dependencies: dependencies:
'@types/node': 20.19.43 '@types/node': 20.19.43
@@ -4778,6 +4795,8 @@ snapshots:
node-releases@2.0.51: {} node-releases@2.0.51: {}
nodemailer@9.0.3: {}
npm-run-path@4.0.1: npm-run-path@4.0.1:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
+3 -201
View File
@@ -1,203 +1,5 @@
import { import { redirect } from "next/navigation";
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";
const stats = [ export default function AdminIndexPage() {
{ redirect("/admin/uploads");
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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.title} className="gap-2">
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{stat.title}</CardDescription>
<stat.icon className="size-4 text-muted-foreground" />
</div>
<CardTitle className="text-2xl font-semibold tabular-nums">
{stat.value}
</CardTitle>
</CardHeader>
<CardFooter className="text-xs text-muted-foreground">
<span
className={`inline-flex items-center gap-1 font-medium ${
stat.trend === "up"
? "text-emerald-600"
: "text-destructive"
}`}
>
{stat.trend === "up" ? (
<ArrowUpRight className="size-3" />
) : (
<ArrowDownRight className="size-3" />
)}
{stat.change}
</span>
<span className="ml-1">{stat.desc}</span>
</CardFooter>
</Card>
))}
</div>
<div className="grid gap-4 lg:grid-cols-7">
<Card className="lg:col-span-4">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> 5 </CardDescription>
<CardAction>
<Button variant="ghost" size="sm">
</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="divide-y">
{recentOrders.map((order) => (
<div
key={order.id}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="flex flex-col">
<span className="text-sm font-medium">
{order.customer}
</span>
<span className="text-xs text-muted-foreground">
{order.email}
</span>
</div>
<div className="flex items-center gap-3">
<Badge variant={statusVariant(order.status)}>
{order.status}
</Badge>
<span className="text-sm font-medium tabular-nums">
{order.amount}
</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="grid gap-2">
<Button variant="outline" className="justify-start">
<Users className="size-4" />
</Button>
<Button variant="outline" className="justify-start">
<DollarSign className="size-4" />
</Button>
<Button variant="outline" className="justify-start">
<Activity className="size-4" />
</Button>
<Button variant="outline" className="justify-start">
<TrendingUp className="size-4" />
</Button>
</CardContent>
</Card>
</div>
</div>
);
} }
-86
View File
@@ -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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="site-name"></Label>
<Input id="site-name" defaultValue="鼎星管理后台" />
</div>
<div className="grid gap-2">
<Label htmlFor="site-url"></Label>
<Input
id="site-url"
type="url"
defaultValue="https://admin.dingxing.com"
/>
</div>
</CardContent>
<CardFooter className="justify-end gap-2">
<Button variant="ghost"></Button>
<Button></Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="notify-email"></Label>
<Input
id="notify-email"
type="email"
defaultValue="admin@dingxing.com"
/>
</div>
</CardContent>
<CardFooter className="justify-end gap-2">
<Button variant="ghost"></Button>
<Button></Button>
</CardFooter>
</Card>
<Separator />
<Card>
<CardHeader>
<CardTitle className="text-destructive"></CardTitle>
<CardDescription></CardDescription>
<CardAction>
<Button variant="destructive" size="sm">
</Button>
</CardAction>
</CardHeader>
</Card>
</div>
);
}
-163
View File
@@ -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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> {total} </CardDescription>
<CardAction>
<Button size="sm">
<Plus className="size-4" />
</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-2">
<div className="relative max-w-sm flex-1">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="搜索用户..." className="pl-8" />
</div>
<Button variant="outline" size="sm">
<Filter className="size-4" />
</Button>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-10 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-8 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="size-8">
<AvatarImage
src={user.image ?? ""}
alt={user.name}
/>
<AvatarFallback className="text-xs">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
{user.id === session?.user.id && (
<span className="text-xs text-muted-foreground">
</span>
)}
</div>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell>
<TableCell>
<Badge
variant={
user.role === "admin" ? "default" : "outline"
}
>
{user.role === "admin" ? "管理员" : "普通用户"}
</Badge>
</TableCell>
<TableCell>
{user.banned ? (
<Badge variant="destructive"></Badge>
) : (
<Badge className="bg-emerald-600"></Badge>
)}
</TableCell>
<TableCell className="text-right">
<UserActions
user={{
id: user.id,
name: user.name,
email: user.email,
role: user.role ?? "user",
banned: user.banned ?? false,
}}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
+3 -4
View File
@@ -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 { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip"; 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({ export default async function AdminLayout({
children, children,
+1 -15
View File
@@ -18,7 +18,6 @@ import { authClient } from "@/lib/auth-client";
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -30,8 +29,7 @@ export default function LoginPage() {
const { error } = await authClient.signIn.magicLink({ const { error } = await authClient.signIn.magicLink({
email, email,
name: name || undefined, callbackURL: "/admin/uploads",
callbackURL: "/admin",
}); });
setLoading(false); setLoading(false);
@@ -70,7 +68,6 @@ export default function LoginPage() {
onClick={() => { onClick={() => {
setSent(false); setSent(false);
setEmail(""); setEmail("");
setName("");
}} }}
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
@@ -96,17 +93,6 @@ export default function LoginPage() {
</CardHeader> </CardHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<CardContent className="grid gap-3"> <CardContent className="grid gap-3">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input
id="name"
type="text"
placeholder="首次登录时用于注册"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email"></Label> <Label htmlFor="email"></Label>
<Input <Input
+2 -2
View File
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "鼎", title: "鼎",
description: "鼎平台", description: "鼎平台",
}; };
export default function RootLayout({ export default function RootLayout({
+17 -84
View File
@@ -3,15 +3,7 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { import { CloudUpload, LogOut, ChevronsUpDown } from "lucide-react";
LayoutDashboard,
Users,
Settings,
ChevronRight,
LogOut,
ChevronsUpDown,
CloudUpload,
} from "lucide-react";
import { import {
Sidebar, Sidebar,
@@ -22,17 +14,9 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarFooter, SidebarFooter,
SidebarRail, SidebarRail,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
@@ -48,26 +32,8 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
type NavItem = { const navData = [
title: string;
url: string;
icon: React.ComponentType<{ className?: string }>;
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" },
],
},
{ title: "文件管理", url: "/admin/uploads", icon: CloudUpload }, { title: "文件管理", url: "/admin/uploads", icon: CloudUpload },
{ title: "设置", url: "/admin/settings", icon: Settings },
]; ];
type SidebarUser = { type SidebarUser = {
@@ -83,8 +49,7 @@ export function AppSidebar({
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const isActive = (url: string) => const isActive = (url: string) => pathname.startsWith(url);
url === "/admin" ? pathname === url : pathname.startsWith(url);
return ( return (
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>
@@ -93,14 +58,14 @@ export function AppSidebar({
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
size="lg" size="lg"
tooltip="鼎管理后台" tooltip="鼎管理后台"
render={<Link href="/admin" />} render={<Link href="/admin/uploads" />}
> >
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<span className="text-sm font-bold">DX</span> <span className="text-sm font-bold">DX</span>
</div> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold"></span> <span className="truncate font-semibold"></span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
Admin Console Admin Console
</span> </span>
@@ -114,50 +79,18 @@ export function AppSidebar({
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel></SidebarGroupLabel> <SidebarGroupLabel></SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{navData.map((item) => {navData.map((item) => (
item.items ? ( <SidebarMenuItem key={item.title}>
<Collapsible <SidebarMenuButton
key={item.title} isActive={isActive(item.url)}
defaultOpen={item.items.some((sub) => isActive(sub.url))} tooltip={item.title}
className="group/collapsible [&[data-open]>li>button>svg:last-child]:rotate-90" render={<Link href={item.url} />}
> >
<SidebarMenuItem> <item.icon className="size-4" />
<CollapsibleTrigger <span>{item.title}</span>
render={<SidebarMenuButton tooltip={item.title} />} </SidebarMenuButton>
> </SidebarMenuItem>
<item.icon className="size-4" /> ))}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200" />
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items.map((sub) => (
<SidebarMenuSubItem key={sub.url}>
<SidebarMenuSubButton
render={<Link href={sub.url} />}
isActive={isActive(sub.url)}
>
<span>{sub.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
) : (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
isActive={isActive(item.url)}
tooltip={item.title}
render={<Link href={item.url} />}
>
<item.icon className="size-4" />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
)}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
+1 -1
View File
@@ -43,7 +43,7 @@ function deriveBreadcrumbs(pathname: string) {
const crumbs = segments.map((seg, i) => ({ const crumbs = segments.map((seg, i) => ({
title: title:
seg === "admin" seg === "admin"
? "首页" ? "文件管理"
: seg.charAt(0).toUpperCase() + seg.slice(1), : seg.charAt(0).toUpperCase() + seg.slice(1),
url: "/" + segments.slice(0, i + 1).join("/"), url: "/" + segments.slice(0, i + 1).join("/"),
})); }));
-108
View File
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" disabled={loading} />
}
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
<span className="sr-only"></span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem onClick={() => setRole("admin")}>
<ShieldCheck className="size-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRole("user")}>
<User className="size-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={toggleBan}>
{user.banned ? (
<>
<CircleCheck className="size-4" />
</>
) : (
<>
<Ban className="size-4" />
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+5 -6
View File
@@ -1,10 +1,10 @@
import { db } from "@/db"; import { db } from "@/db";
import * as schema from "@/db/schema"; import * as schema from "@/db/schema";
import { sendMagicLinkEmail } from "@/lib/mail";
import { ac, adminRole, userRole } from "@/lib/permissions";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin } from "better-auth/plugins"; import { admin, magicLink } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins";
import { ac, adminRole, userRole } from "@/lib/permissions";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -27,9 +27,8 @@ export const auth = betterAuth({
}, },
}), }),
magicLink({ magicLink({
sendMagicLink: async ({ email, url, token }, ctx) => { sendMagicLink: async ({ email, url }) => {
// TODO: 接入邮件服务(如 Resend)后替换为真实发信 await sendMagicLinkEmail(email, url);
console.log(`[Magic Link] 发送至 ${email},链接: ${url}`);
}, },
expiresIn: 60 * 5, // 5 分钟过期 expiresIn: 60 * 5, // 5 分钟过期
}), }),
+85
View File
@@ -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 `
<!DOCTYPE html>
<html lang="zh-CN">
<body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 0;">
<tr>
<td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<tr>
<td style="background:#18181b;padding:24px 40px;text-align:center;">
<span style="font-size:20px;font-weight:bold;color:#fafafa;">鼎兴</span>
</td>
</tr>
<tr>
<td style="padding:40px;">
<h1 style="margin:0 0 16px;font-size:22px;color:#18181b;">登录到鼎兴管理后台</h1>
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#52525b;">
你收到这封邮件是因为有人请求使用此邮箱登录鼎兴。点击下方按钮即可完成登录:
</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<a href="${url}"
style="display:inline-block;padding:12px 32px;background:#18181b;color:#fafafa;text-decoration:none;font-size:15px;font-weight:600;border-radius:8px;">
登录
</a>
</td>
</tr>
</table>
<p style="margin:24px 0 0;font-size:13px;color:#a1a1aa;">
此链接将在 5 分钟后失效。如果你没有发起此请求,请忽略此邮件。
</p>
<hr style="margin:24px 0;border:none;border-top:1px solid #e4e4e7;">
<p style="margin:0;font-size:12px;color:#a1a1aa;word-break:break-all;">
或者复制此链接到浏览器:<br>${url}
</p>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:12px;color:#a1a1aa;">
© ${new Date().getFullYear()} 鼎兴. 保留所有权利.
</p>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}