feat(mail): 接入邮件服务并简化管理后台
- 新增 nodemailer 依赖及 SMTP 邮件发送模块 - 替换 magic link 控制台日志为真实邮件发送 - 移除仪表盘页面,管理后台默认跳转至文件管理 - 精简侧边栏导航,移除用户管理与设置入口 - 移除登录页姓名字段,品牌更名为“鼎兴” - 关闭 biome 自动组织导入
This commit is contained in:
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
"assist": {
|
"assist": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"source": {
|
"source": {
|
||||||
"organizeImports": "on"
|
"organizeImports": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+19
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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,38 +79,7 @@ export function AppSidebar({
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>管理</SidebarGroupLabel>
|
<SidebarGroupLabel>管理</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{navData.map((item) =>
|
{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"
|
|
||||||
>
|
|
||||||
<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}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={isActive(item.url)}
|
isActive={isActive(item.url)}
|
||||||
@@ -156,8 +90,7 @@ export function AppSidebar({
|
|||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|||||||
@@ -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("/"),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 { 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 分钟过期
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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