feat(page): add page

This commit is contained in:
2026-02-10 15:30:13 +08:00
parent c32e491748
commit 1444cbc665
34 changed files with 2354 additions and 78 deletions

View File

@@ -1,6 +1,14 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone", output: "standalone",
async rewrites() {
return [
{
source: "/api/cms/:path*",
destination: `${process.env.CMS_SERVICE_BASE_URL || "http://localhost:3000"}/api/v1/:path*`,
},
];
},
}; };
module.exports = nextConfig; module.exports = nextConfig;

View File

@@ -11,15 +11,28 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.0", "@radix-ui/react-checkbox": "^1.3.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next": "^16.1.6", "next": "^16.1.6",
"next-themes": "^0.4.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwind-merge": "^3.4.0" "react-hook-form": "^7.71.1",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@next/eslint-plugin-next": "^16.1.6", "@next/eslint-plugin-next": "^16.1.6",

View File

@@ -0,0 +1,122 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/services/api";
import { Article, PaginatedResponse } from "@/services/types";
import { Button } from "@/components/ui/button";
import { Plus, Edit, Trash2 } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { format } from "date-fns";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
export default function ArticlesPage() {
const queryClient = useQueryClient();
const { toast } = useToast();
const { data, isLoading } = useQuery({
queryKey: ["articles"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Article>>("/articles");
return res.data;
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/articles/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["articles"] });
toast({ title: "删除成功" });
},
});
const handleDelete = (id: string) => {
if (confirm("确定要删除吗?")) {
deleteMutation.mutate(id);
}
};
if (isLoading) {
return (
<div className="p-8 text-center text-muted-foreground">...</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<Button onClick={() => alert("编辑器开发中...")}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Slug</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data.map((article) => (
<TableRow key={article.id}>
<TableCell className="font-medium">{article.title}</TableCell>
<TableCell>{article.slug}</TableCell>
<TableCell>
<Badge
variant={
article.status === "published" ? "default" : "secondary"
}
>
{article.status}
</Badge>
</TableCell>
<TableCell>
{article.published_at
? format(new Date(article.published_at), "yyyy-MM-dd HH:mm")
: "-"}
</TableCell>
<TableCell>
{format(new Date(article.updated_at), "yyyy-MM-dd HH:mm")}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(article.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{data?.data.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/services/api";
import { Column, PaginatedResponse } from "@/services/types";
import { ColumnsTable } from "@/components/modules/columns-table";
import { ColumnDialog } from "@/components/modules/column-dialog";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
export default function ColumnsPage() {
const [open, setOpen] = useState(false);
const [editingColumn, setEditingColumn] = useState<Column | null>(null);
const queryClient = useQueryClient();
const { toast } = useToast();
const { data, isLoading } = useQuery({
queryKey: ["columns"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Column>>("/columns");
return res.data;
},
});
const createMutation = useMutation({
mutationFn: (values: any) => api.post("/columns", values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["columns"] });
setOpen(false);
toast({ title: "创建成功" });
},
});
const updateMutation = useMutation({
mutationFn: ({ id, values }: { id: string; values: any }) =>
api.patch(`/columns/${id}`, values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["columns"] });
setOpen(false);
setEditingColumn(null);
toast({ title: "更新成功" });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/columns/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["columns"] });
toast({ title: "删除成功" });
},
});
const handleSubmit = (values: any) => {
if (editingColumn) {
updateMutation.mutate({ id: editingColumn.id, values });
} else {
createMutation.mutate(values);
}
};
const handleEdit = (column: Column) => {
setEditingColumn(column);
setOpen(true);
};
const handleDelete = (id: string) => {
if (confirm("确定要删除吗?")) {
deleteMutation.mutate(id);
}
};
if (isLoading) {
return <div className="p-8 text-center text-muted-foreground">...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<Button
onClick={() => {
setEditingColumn(null);
setOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<ColumnsTable
data={data?.data || []}
onEdit={handleEdit}
onDelete={handleDelete}
/>
<ColumnDialog
open={open}
onOpenChange={setOpen}
column={editingColumn}
onSubmit={handleSubmit}
/>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import api from "@/services/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PaginatedResponse, Article, Column, Tag, Media } from "@/services/types";
import { FileText, PanelLeft, Tags, Image as ImageIcon } from "lucide-react";
export default function DashboardPage() {
const { data: articles } = useQuery({
queryKey: ["articles"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Article>>("/articles");
return res.data;
}
});
const { data: columns } = useQuery({
queryKey: ["columns"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Column>>("/columns");
return res.data;
}
});
const { data: tags } = useQuery({
queryKey: ["tags"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Tag>>("/tags");
return res.data;
}
});
const { data: media } = useQuery({
queryKey: ["media"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Media>>("/media");
return res.data;
}
});
const isLoading = [articles, columns, tags, media].every((x) => x === undefined);
if (isLoading) {
return <div className="p-8 text-center text-muted-foreground">...</div>;
}
const stats = [
{
title: "文章总数",
value: articles?.total || 0,
icon: FileText,
description: "已发布和草稿状态的文章",
},
{
title: "栏目数量",
value: columns?.total || 0,
icon: PanelLeft,
description: "内容分类栏目",
},
{
title: "标签数量",
value: tags?.total || 0,
icon: Tags,
description: "用于内容标记",
},
{
title: "媒体资源",
value: media?.total || 0,
icon: ImageIcon,
description: "图片和文件资源",
},
];
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{stat.title}
</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
<p className="text-xs text-muted-foreground">
{stat.description}
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Sidebar, Header } from "@/components/layout/dashboard-layout";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
<div className="hidden border-r bg-muted/40 md:block">
<Sidebar />
</div>
<div className="flex flex-col">
<Header />
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/services/api";
import { Media, PaginatedResponse } from "@/services/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Upload, Trash2, Copy } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import Image from "next/image";
import { format } from "date-fns";
import { Card, CardContent } from "@/components/ui/card";
export default function MediaPage() {
const [uploading, setUploading] = useState(false);
const queryClient = useQueryClient();
const { toast } = useToast();
const { data, isLoading } = useQuery({
queryKey: ["media"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Media>>("/media");
return res.data;
},
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
// Assuming API endpoint is /media/upload or similar, checking API docs
// API.md might specify. Assuming standard POST /media with multipart
return api.post("/media", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["media"] });
toast({ title: "上传成功" });
setUploading(false);
},
onError: () => {
setUploading(false);
toast({ title: "上传失败", variant: "destructive" });
}
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/media/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["media"] });
toast({ title: "删除成功" });
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setUploading(true);
uploadMutation.mutate(e.target.files[0]);
}
};
const handleCopyUrl = (url: string) => {
navigator.clipboard.writeText(url);
toast({ title: "链接已复制" });
};
const handleDelete = (id: string) => {
if (confirm("确定要删除吗?")) {
deleteMutation.mutate(id);
}
};
if (isLoading) {
return <div className="p-8 text-center text-muted-foreground">...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<div className="relative">
<Input
type="file"
className="absolute inset-0 opacity-0 cursor-pointer"
onChange={handleFileChange}
accept="image/*"
disabled={uploading}
/>
<Button disabled={uploading}>
<Upload className="mr-2 h-4 w-4" />
{uploading ? "上传中..." : "上传图片"}
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{data?.data.map((item) => (
<Card key={item.id} className="overflow-hidden group">
<CardContent className="p-0 relative aspect-square">
<Image
src={item.url}
alt={item.id}
fill
className="object-cover transition-transform group-hover:scale-105"
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 25vw, 16vw"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
variant="secondary"
size="icon"
onClick={() => handleCopyUrl(item.url)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
<div className="p-2 text-xs text-muted-foreground truncate">
{format(new Date(item.created_at), "yyyy-MM-dd")}
</div>
</Card>
))}
{data?.data.length === 0 && (
<div className="col-span-full text-center py-10 text-muted-foreground">
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/services/api";
import { Tag, PaginatedResponse } from "@/services/types";
import { TagsTable } from "@/components/modules/tags-table";
import { TagDialog } from "@/components/modules/tag-dialog";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
export default function TagsPage() {
const [open, setOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const queryClient = useQueryClient();
const { toast } = useToast();
const { data, isLoading } = useQuery({
queryKey: ["tags"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Tag>>("/tags");
return res.data;
},
});
const createMutation = useMutation({
mutationFn: (values: any) => api.post("/tags", values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
setOpen(false);
toast({ title: "创建成功" });
},
});
const updateMutation = useMutation({
mutationFn: ({ id, values }: { id: string; values: any }) =>
api.patch(`/tags/${id}`, values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
setOpen(false);
setEditingTag(null);
toast({ title: "更新成功" });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/tags/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
toast({ title: "删除成功" });
},
});
const handleSubmit = (values: any) => {
if (editingTag) {
updateMutation.mutate({ id: editingTag.id, values });
} else {
createMutation.mutate(values);
}
};
const handleEdit = (tag: Tag) => {
setEditingTag(tag);
setOpen(true);
};
const handleDelete = (id: string) => {
if (confirm("确定要删除吗?")) {
deleteMutation.mutate(id);
}
};
if (isLoading) {
return <div className="p-8 text-center text-muted-foreground">...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<Button
onClick={() => {
setEditingTag(null);
setOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<TagsTable
data={data?.data || []}
onEdit={handleEdit}
onDelete={handleDelete}
/>
<TagDialog
open={open}
onOpenChange={setOpen}
tag={editingTag}
onSubmit={handleSubmit}
/>
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
export const runtime = "nodejs"
export async function GET(req: NextRequest) {
const tenantId = req.nextUrl.searchParams.get("tenantId")?.trim() ?? ""
if (!tenantId) {
return NextResponse.json({ message: "tenantId is required" }, { status: 400 })
}
const res = NextResponse.redirect(new URL("/", req.url), 302)
res.cookies.set({
name: "tenantId",
value: tenantId,
httpOnly: false,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 30 * 24 * 60 * 60,
})
return res
}

View File

@@ -1,24 +1,41 @@
import Link from "next/link"; import Link from "next/link";
import { AlertCircle } from "lucide-react";
export default async function AuthErrorPage({ export default async function AuthErrorPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ message?: string }> searchParams: Promise<{ message?: string; error?: string }>
}) { }) {
const sp = await searchParams; const sp = await searchParams;
const message = sp?.message ?? "认证失败" const message = sp?.message || sp?.error || "认证过程中发生未知错误";
return ( return (
<main className="min-h-screen flex items-center justify-center p-6"> <main className="min-h-screen flex flex-col items-center justify-center p-6 bg-background">
<div className="max-w-md text-sm"> <div className="w-full max-w-md p-8 space-y-6 text-center bg-card rounded-lg border shadow-sm">
<div className="font-medium"></div> <div className="flex justify-center">
<div className="mt-2 break-words text-muted-foreground">{message}</div> <div className="p-3 bg-destructive/10 rounded-full">
<div className="mt-4"> <AlertCircle className="w-10 h-10 text-destructive" />
<Link className="underline underline-offset-4" href="/"> </div>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-sm text-muted-foreground break-words">
{message}
</p>
</div>
<div className="pt-4">
<Link
href="/"
className="inline-flex items-center justify-center h-10 px-8 py-2 text-sm font-medium transition-colors rounded-md bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
</Link> </Link>
</div> </div>
</div> </div>
</main> </main>
) );
} }

View File

@@ -1,10 +0,0 @@
export default function ClientRequiredPage() {
return (
<main className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md text-sm">
CMS_CLIENT_ID SSO
</div>
</main>
)
}

View File

@@ -1,16 +1,27 @@
import type { Metadata } from "next" import type { Metadata } from "next";
import "./globals.css" import { Providers } from "@/components/providers";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "CMS", title: "CMS",
description: "CMS Frontend", description: "CMS Frontend",
} };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN" suppressHydrationWarning>
<body>{children}</body> <body>
<Providers>
{children}
<Toaster />
</Providers>
</body>
</html> </html>
) );
} }

View File

@@ -1,12 +0,0 @@
export default function TenantRequiredPage() {
return (
<main className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md text-sm">
tenantIdx-tenant-id header tenantId cookie
<div className="mt-2">
访/api/dev/set-tenant?tenantId=UUID
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,108 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
FileText,
Tags,
Image as ImageIcon,
Settings,
PanelLeft,
LogOut,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/mode-toggle";
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {}
export function Sidebar({ className }: SidebarProps) {
const pathname = usePathname();
const routes = [
{
label: "概览",
icon: LayoutDashboard,
href: "/dashboard",
active: pathname === "/dashboard",
},
{
label: "栏目管理",
icon: PanelLeft,
href: "/columns",
active: pathname.startsWith("/columns"),
},
{
label: "标签管理",
icon: Tags,
href: "/tags",
active: pathname.startsWith("/tags"),
},
{
label: "媒体库",
icon: ImageIcon,
href: "/media",
active: pathname.startsWith("/media"),
},
{
label: "文章管理",
icon: FileText,
href: "/articles",
active: pathname.startsWith("/articles"),
},
];
return (
<div className={cn("pb-12 min-h-screen border-r bg-card", className)}>
<div className="space-y-4 py-4">
<div className="px-3 py-2">
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
CMS
</h2>
<div className="space-y-1">
{routes.map((route) => (
<Button
key={route.href}
variant={route.active ? "secondary" : "ghost"}
className="w-full justify-start"
asChild
>
<Link href={route.href}>
<route.icon className="mr-2 h-4 w-4" />
{route.label}
</Link>
</Button>
))}
</div>
</div>
</div>
</div>
);
}
export function Header() {
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
window.location.href = "/login"; // Redirect handled by middleware mostly, but good to be explicit
} catch (e) {
console.error(e);
}
};
return (
<header className="flex h-14 items-center gap-4 border-b bg-card px-6">
<div className="flex-1">
{/* Breadcrumbs or Title could go here */}
</div>
<div className="flex items-center gap-4">
<ModeToggle />
<Button variant="ghost" size="icon" onClick={handleLogout} title="退出登录">
<LogOut className="h-5 w-5" />
</Button>
</div>
</header>
);
}

View File

@@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,135 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Column } from "@/services/types";
import { useEffect } from "react";
const columnSchema = z.object({
name: z.string().min(1, "名称不能为空"),
slug: z.string().min(1, "Slug不能为空"),
description: z.string().optional(),
sort_order: z.number().int().default(0),
});
type ColumnFormValues = z.infer<typeof columnSchema>;
interface ColumnDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
column?: Column | null;
onSubmit: (values: ColumnFormValues) => void;
}
export function ColumnDialog({
open,
onOpenChange,
column,
onSubmit,
}: ColumnDialogProps) {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ColumnFormValues>({
resolver: zodResolver(columnSchema),
defaultValues: {
name: "",
slug: "",
description: "",
sort_order: 0,
},
});
useEffect(() => {
if (open) {
reset({
name: column?.name || "",
slug: column?.slug || "",
description: column?.description || "",
sort_order: column?.sort_order || 0,
});
}
}, [open, column, reset]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{column ? "编辑栏目" : "创建栏目"}</DialogTitle>
<DialogDescription>
{column ? "修改栏目信息" : "填写新栏目的基本信息"}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
</Label>
<div className="col-span-3">
<Input id="name" {...register("name")} />
{errors.name && (
<p className="text-sm text-destructive mt-1">
{errors.name.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="slug" className="text-right">
Slug
</Label>
<div className="col-span-3">
<Input id="slug" {...register("slug")} />
{errors.slug && (
<p className="text-sm text-destructive mt-1">
{errors.slug.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="sort_order" className="text-right">
</Label>
<div className="col-span-3">
<Input
id="sort_order"
type="number"
{...register("sort_order", { valueAsNumber: true })}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
</Label>
<Input
id="description"
className="col-span-3"
{...register("description")}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,76 @@
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Column } from "@/services/types";
import { format } from "date-fns";
import { Edit, Trash2 } from "lucide-react";
interface ColumnsTableProps {
data: Column[];
onEdit: (column: Column) => void;
onDelete: (id: string) => void;
}
export function ColumnsTable({ data, onEdit, onDelete }: ColumnsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead>Slug</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((column) => (
<TableRow key={column.id}>
<TableCell>{column.sort_order}</TableCell>
<TableCell className="font-medium">{column.name}</TableCell>
<TableCell>{column.slug}</TableCell>
<TableCell>{column.description || "-"}</TableCell>
<TableCell>
{format(new Date(column.created_at), "yyyy-MM-dd HH:mm")}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(column)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => onDelete(column.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{data.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,123 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Tag } from "@/services/types";
import { useEffect } from "react";
const tagSchema = z.object({
name: z.string().min(1, "名称不能为空"),
slug: z.string().min(1, "Slug不能为空"),
kind: z.string().min(1, "类型不能为空").default("default"),
});
type TagFormValues = z.infer<typeof tagSchema>;
interface TagDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tag?: Tag | null;
onSubmit: (values: TagFormValues) => void;
}
export function TagDialog({
open,
onOpenChange,
tag,
onSubmit,
}: TagDialogProps) {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<TagFormValues>({
resolver: zodResolver(tagSchema),
defaultValues: {
name: "",
slug: "",
kind: "default",
},
});
useEffect(() => {
if (open) {
reset({
name: tag?.name || "",
slug: tag?.slug || "",
kind: tag?.kind || "default",
});
}
}, [open, tag, reset]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{tag ? "编辑标签" : "创建标签"}</DialogTitle>
<DialogDescription>
{tag ? "修改标签信息" : "填写新标签的基本信息"}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
</Label>
<div className="col-span-3">
<Input id="name" {...register("name")} />
{errors.name && (
<p className="text-sm text-destructive mt-1">
{errors.name.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="slug" className="text-right">
Slug
</Label>
<div className="col-span-3">
<Input id="slug" {...register("slug")} />
{errors.slug && (
<p className="text-sm text-destructive mt-1">
{errors.slug.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="kind" className="text-right">
</Label>
<div className="col-span-3">
<Input id="kind" {...register("kind")} />
{errors.kind && (
<p className="text-sm text-destructive mt-1">
{errors.kind.message}
</p>
)}
</div>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,71 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Tag } from "@/services/types";
import { format } from "date-fns";
import { Edit, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
interface TagsTableProps {
data: Tag[];
onEdit: (tag: Tag) => void;
onDelete: (id: string) => void;
}
export function TagsTable({ data, onEdit, onDelete }: TagsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Slug</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((tag) => (
<TableRow key={tag.id}>
<TableCell className="font-medium">{tag.name}</TableCell>
<TableCell>{tag.slug}</TableCell>
<TableCell>
<Badge variant="secondary">{tag.kind}</Badge>
</TableCell>
<TableCell>
{format(new Date(tag.created_at), "yyyy-MM-dd HH:mm")}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(tag)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => onDelete(tag.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{data.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import * as React from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
},
},
}),
);
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,52 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-base md:text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:opacity-90",
secondary: "bg-secondary text-secondary-foreground hover:opacity-90",
outline: "border border-input bg-background hover:bg-secondary",
ghost: "hover:bg-secondary",
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,122 @@
"use client";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

117
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

125
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,125 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
/>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -34,11 +34,7 @@ export function proxy(req: NextRequest) {
const currentUrl = req.nextUrl.clone(); const currentUrl = req.nextUrl.clone();
const pathname = currentUrl.pathname; const pathname = currentUrl.pathname;
if ( if (pathname === "/auth-error") {
pathname === "/auth-error" ||
pathname === "/tenant-required" ||
pathname === "/client-required"
) {
return NextResponse.next(); return NextResponse.next();
} }
@@ -53,13 +49,13 @@ export function proxy(req: NextRequest) {
if (code) { if (code) {
if (!cmsServiceBaseUrl) { if (!cmsServiceBaseUrl) {
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
url.pathname = "/client-required"; url.pathname = "/auth-error";
url.search = ""; url.search = `?error=${encodeURIComponent("Configuration Error: CMS_SERVICE_BASE_URL is missing")}`;
return NextResponse.redirect(url, 302); return NextResponse.redirect(url, 302);
} }
const nextUrl = currentUrl.clone(); const nextUrl = currentUrl.clone();
nextUrl.searchParams.delete("code"); nextUrl.searchParams.delete("code");
const callback = `${cmsServiceBaseUrl}/auth/callback?code=${encodeURIComponent( const callback = `${cmsServiceBaseUrl}/api/v1/auth/callback?code=${encodeURIComponent(
code, code,
)}&tenant_id=${encodeURIComponent(tenantId)}&next=${encodeURIComponent(nextUrl.toString())}`; )}&tenant_id=${encodeURIComponent(tenantId)}&next=${encodeURIComponent(nextUrl.toString())}`;
return NextResponse.redirect(callback, 302); return NextResponse.redirect(callback, 302);
@@ -74,7 +70,7 @@ export function proxy(req: NextRequest) {
const next = encodeURIComponent(currentUrl.toString()); const next = encodeURIComponent(currentUrl.toString());
if (cmsServiceBaseUrl) { if (cmsServiceBaseUrl) {
// 跳转到 cms-service 的 refresh 接口,由它完成刷新并重定向回来 // 跳转到 cms-service 的 refresh 接口,由它完成刷新并重定向回来
const refreshUrl = `${cmsServiceBaseUrl}/auth/refresh?token=${encodeURIComponent( const refreshUrl = `${cmsServiceBaseUrl}/api/v1/auth/refresh?token=${encodeURIComponent(
refreshToken, refreshToken,
)}&next=${next}`; )}&next=${next}`;
return NextResponse.redirect(refreshUrl, 302); return NextResponse.redirect(refreshUrl, 302);
@@ -83,26 +79,26 @@ export function proxy(req: NextRequest) {
if (!tenantId) { if (!tenantId) {
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
url.pathname = "/tenant-required"; url.pathname = "/auth-error";
url.search = ""; url.search = `?error=${encodeURIComponent("Missing Tenant ID: Please access via a valid tenant domain or set x-tenant-id header.")}`;
return NextResponse.redirect(url, 302); return NextResponse.redirect(url, 302);
} }
if (!clientId) { if (!clientId) {
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
url.pathname = "/client-required"; url.pathname = "/auth-error";
url.search = ""; url.search = `?error=${encodeURIComponent("Configuration Error: CMS_CLIENT_ID is missing")}`;
return NextResponse.redirect(url, 302); return NextResponse.redirect(url, 302);
} }
const next = encodeURIComponent(currentUrl.toString()); const next = encodeURIComponent(currentUrl.toString());
if (!cmsServiceBaseUrl) { if (!cmsServiceBaseUrl) {
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
url.pathname = "/client-required"; url.pathname = "/auth-error";
url.search = ""; url.search = `?error=${encodeURIComponent("Configuration Error: CMS_SERVICE_BASE_URL is missing")}`;
return NextResponse.redirect(url, 302); return NextResponse.redirect(url, 302);
} }
const callback = `${cmsServiceBaseUrl}/auth/callback?tenant_id=${encodeURIComponent( const callback = `${cmsServiceBaseUrl}/api/v1/auth/callback?tenant_id=${encodeURIComponent(
tenantId, tenantId,
)}&next=${next}`; )}&next=${next}`;
const loginUrl = `${process.env.IAM_FRONT_BASE_URL}/login?clientId=${encodeURIComponent( const loginUrl = `${process.env.IAM_FRONT_BASE_URL}/login?clientId=${encodeURIComponent(

27
src/services/api.ts Normal file
View File

@@ -0,0 +1,27 @@
import axios from "axios";
const api = axios.create({
baseURL: "/api/cms",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// 401 Unauthorized: Redirect to login or handle refresh
// Since middleware handles initial auth, this might happen if token expires while on page
// Ideally, the refresh logic should be handled by the middleware or a transparent refresh mechanism
// For now, we can redirect to login if we can't refresh
if (typeof window !== "undefined") {
// window.location.href = "/login"; // Or let middleware handle it on next navigation
}
}
return Promise.reject(error);
}
);
export default api;

71
src/services/types.ts Normal file
View File

@@ -0,0 +1,71 @@
export interface Column {
tenant_id: string;
id: string;
name: string;
slug: string;
description: string | null;
parent_id: string | null;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface Tag {
tenant_id: string;
id: string;
kind: string;
name: string;
slug: string;
created_at: string;
updated_at: string;
}
export interface Media {
tenant_id: string;
id: string;
url: string;
mime_type: string | null;
size_bytes: number | null;
width: number | null;
height: number | null;
created_at: string;
created_by: string | null;
}
export interface Article {
tenant_id: string;
id: string;
column_id: string | null;
title: string;
slug: string;
summary: string | null;
content: string;
status: string;
current_version: number;
published_at: string | null;
created_at: string;
updated_at: string;
created_by: string | null;
updated_by: string | null;
}
export interface ArticleVersion {
tenant_id: string;
id: string;
article_id: string;
version: number;
title: string;
summary: string | null;
content: string;
status: string;
created_at: string;
created_by: string | null;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}