Initial fullstack project setup with Next.js 15, Gin, PostgreSQL and Docker Compose
- Frontend: Next.js 15 (App Router), Auth.js v5, shadcn/ui, MagicUI - Backend: Go + Gin + GORM with layered architecture - Auth: Local credentials login with optional Keycloak OAuth binding - Admin: RBAC user management for admin role - Dev: Docker Compose with hot reload for both frontend and backend - Docker: 3-service orchestration (frontend, backend, postgres) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
186
frontend/app/(main)/admin/page.tsx
Normal file
186
frontend/app/(main)/admin/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
});
|
||||
|
||||
async function fetchUsers() {
|
||||
const res = await fetch("/api/proxy/admin/users");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/proxy/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setOpen(false);
|
||||
setForm({ username: "", email: "", password: "", role: "user" });
|
||||
fetchUsers();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm("确定删除该用户?")) return;
|
||||
const res = await fetch(`/api/proxy/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchUsers();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">用户管理</h1>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button>创建用户</Button>} />
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建用户</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<Label>用户名</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, username: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, email: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, password: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>角色</Label>
|
||||
<select
|
||||
className="w-full rounded-md border px-3 py-2 text-sm"
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
创建
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用户列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<p>加载中...</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.role}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/app/(main)/dashboard/page.tsx
Normal file
32
frontend/app/(main)/dashboard/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { auth } from "@/auth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
const user = session?.user as any;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">欢迎,{user?.name || user?.email}</h1>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用户信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">用户名:</span>
|
||||
{user?.name}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">邮箱:</span>
|
||||
{user?.email}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">角色:</span>
|
||||
{user?.role}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/app/(main)/layout.tsx
Normal file
46
frontend/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { signOut } from "@/auth";
|
||||
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const user = session?.user as any;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||
<Link href="/" className="text-lg font-bold">
|
||||
EvanPage
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link href="/dashboard" className="text-sm hover:underline">
|
||||
仪表盘
|
||||
</Link>
|
||||
{user?.role === "admin" && (
|
||||
<Link href="/admin" className="text-sm hover:underline">
|
||||
管理后台
|
||||
</Link>
|
||||
)}
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="sm" type="submit">
|
||||
退出
|
||||
</Button>
|
||||
</form>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user