feat(mail): 接入邮件服务并简化管理后台
- 新增 nodemailer 依赖及 SMTP 邮件发送模块 - 替换 magic link 控制台日志为真实邮件发送 - 移除仪表盘页面,管理后台默认跳转至文件管理 - 精简侧边栏导航,移除用户管理与设置入口 - 移除登录页姓名字段,品牌更名为“鼎兴” - 关闭 biome 自动组织导入
This commit is contained in:
+1
-1
@@ -30,7 +30,7 @@
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
"organizeImports": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+19
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
export default function AdminIndexPage() {
|
||||
redirect("/admin/uploads");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null>(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("");
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
@@ -96,17 +93,6 @@ export default function LoginPage() {
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<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">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "鼎星",
|
||||
description: "鼎星平台",
|
||||
title: "鼎兴",
|
||||
description: "鼎兴平台",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -3,15 +3,7 @@
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
ChevronsUpDown,
|
||||
CloudUpload,
|
||||
} from "lucide-react";
|
||||
import { CloudUpload, LogOut, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -22,17 +14,9 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarFooter,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
@@ -48,26 +32,8 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
type NavItem = {
|
||||
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" },
|
||||
],
|
||||
},
|
||||
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 (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
@@ -93,14 +58,14 @@ export function AppSidebar({
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
tooltip="鼎星管理后台"
|
||||
render={<Link href="/admin" />}
|
||||
tooltip="鼎兴管理后台"
|
||||
render={<Link href="/admin/uploads" />}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
Admin Console
|
||||
</span>
|
||||
@@ -114,50 +79,18 @@ export function AppSidebar({
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>管理</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{navData.map((item) =>
|
||||
item.items ? (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
defaultOpen={item.items.some((sub) => isActive(sub.url))}
|
||||
className="group/collapsible [&[data-open]>li>button>svg:last-child]:rotate-90"
|
||||
{navData.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive(item.url)}
|
||||
tooltip={item.title}
|
||||
render={<Link href={item.url} />}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger
|
||||
render={<SidebarMenuButton tooltip={item.title} />}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
@@ -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("/"),
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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 分钟过期
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user