From 1444cbc665f1deae40d193a85f49a52c95b2b219 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Tue, 10 Feb 2026 15:30:13 +0800 Subject: [PATCH] feat(page): add page --- next.config.js | 8 + package.json | 15 +- src/app/(dashboard)/articles/page.tsx | 122 +++++++++++++ src/app/(dashboard)/columns/page.tsx | 106 +++++++++++ src/app/(dashboard)/dashboard/page.tsx | 98 ++++++++++ src/app/(dashboard)/layout.tsx | 21 +++ src/app/(dashboard)/media/page.tsx | 139 +++++++++++++++ src/app/(dashboard)/tags/page.tsx | 106 +++++++++++ src/app/api/dev/set-tenant/route.ts | 23 --- src/app/auth-error/page.tsx | 35 +++- src/app/client-required/page.tsx | 10 -- src/app/layout.tsx | 25 ++- src/app/tenant-required/page.tsx | 12 -- src/components/layout/dashboard-layout.tsx | 108 +++++++++++ src/components/mode-toggle.tsx | 40 +++++ src/components/modules/column-dialog.tsx | 135 ++++++++++++++ src/components/modules/columns-table.tsx | 76 ++++++++ src/components/modules/tag-dialog.tsx | 123 +++++++++++++ src/components/modules/tags-table.tsx | 71 ++++++++ src/components/providers.tsx | 30 ++++ src/components/ui/badge.tsx | 36 ++++ src/components/ui/button.tsx | 52 ++++++ src/components/ui/card.tsx | 79 ++++++++ src/components/ui/dialog.tsx | 122 +++++++++++++ src/components/ui/dropdown-menu.tsx | 198 +++++++++++++++++++++ src/components/ui/input.tsx | 24 +++ src/components/ui/label.tsx | 21 +++ src/components/ui/table.tsx | 117 ++++++++++++ src/components/ui/toast.tsx | 125 +++++++++++++ src/components/ui/toaster.tsx | 35 ++++ src/components/ui/use-toast.ts | 194 ++++++++++++++++++++ src/proxy.ts | 28 ++- src/services/api.ts | 27 +++ src/services/types.ts | 71 ++++++++ 34 files changed, 2354 insertions(+), 78 deletions(-) create mode 100644 src/app/(dashboard)/articles/page.tsx create mode 100644 src/app/(dashboard)/columns/page.tsx create mode 100644 src/app/(dashboard)/dashboard/page.tsx create mode 100644 src/app/(dashboard)/layout.tsx create mode 100644 src/app/(dashboard)/media/page.tsx create mode 100644 src/app/(dashboard)/tags/page.tsx delete mode 100644 src/app/api/dev/set-tenant/route.ts delete mode 100644 src/app/client-required/page.tsx delete mode 100644 src/app/tenant-required/page.tsx create mode 100644 src/components/layout/dashboard-layout.tsx create mode 100644 src/components/mode-toggle.tsx create mode 100644 src/components/modules/column-dialog.tsx create mode 100644 src/components/modules/columns-table.tsx create mode 100644 src/components/modules/tag-dialog.tsx create mode 100644 src/components/modules/tags-table.tsx create mode 100644 src/components/providers.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/components/ui/use-toast.ts create mode 100644 src/services/api.ts create mode 100644 src/services/types.ts diff --git a/next.config.js b/next.config.js index c10e07d..458b369 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { 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; diff --git a/package.json b/package.json index 71a893e..b16ee76 100644 --- a/package.json +++ b/package.json @@ -11,15 +11,28 @@ }, "dependencies": { "@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-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-toast": "^1.2.15", + "@tanstack/react-query": "^5.90.20", + "axios": "^1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.468.0", "next": "^16.1.6", + "next-themes": "^0.4.6", "react": "^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": { "@next/eslint-plugin-next": "^16.1.6", diff --git a/src/app/(dashboard)/articles/page.tsx b/src/app/(dashboard)/articles/page.tsx new file mode 100644 index 0000000..919e140 --- /dev/null +++ b/src/app/(dashboard)/articles/page.tsx @@ -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>("/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 ( +
加载中...
+ ); + } + + return ( +
+
+

文章管理

+ +
+ + + + + 标题 + Slug + 状态 + 发布时间 + 更新时间 + 操作 + + + + {data?.data.map((article) => ( + + {article.title} + {article.slug} + + + {article.status} + + + + {article.published_at + ? format(new Date(article.published_at), "yyyy-MM-dd HH:mm") + : "-"} + + + {format(new Date(article.updated_at), "yyyy-MM-dd HH:mm")} + + +
+ + +
+
+
+ ))} + {data?.data.length === 0 && ( + + + 暂无数据 + + + )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/columns/page.tsx b/src/app/(dashboard)/columns/page.tsx new file mode 100644 index 0000000..3dc7986 --- /dev/null +++ b/src/app/(dashboard)/columns/page.tsx @@ -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(null); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const { data, isLoading } = useQuery({ + queryKey: ["columns"], + queryFn: async () => { + const res = await api.get>("/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
加载中...
; + } + + return ( +
+
+

栏目管理

+ +
+ + + + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..f92bf5c --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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>("/articles"); + return res.data; + } + }); + + const { data: columns } = useQuery({ + queryKey: ["columns"], + queryFn: async () => { + const res = await api.get>("/columns"); + return res.data; + } + }); + + const { data: tags } = useQuery({ + queryKey: ["tags"], + queryFn: async () => { + const res = await api.get>("/tags"); + return res.data; + } + }); + + const { data: media } = useQuery({ + queryKey: ["media"], + queryFn: async () => { + const res = await api.get>("/media"); + return res.data; + } + }); + + const isLoading = [articles, columns, tags, media].every((x) => x === undefined); + + if (isLoading) { + return
加载中...
; + } + + 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 ( +
+

概览

+
+ {stats.map((stat) => ( + + + + {stat.title} + + + + +
{stat.value}
+

+ {stat.description} +

+
+
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..872eccd --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -0,0 +1,21 @@ +import { Sidebar, Header } from "@/components/layout/dashboard-layout"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+
+
+
+ {children} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/media/page.tsx b/src/app/(dashboard)/media/page.tsx new file mode 100644 index 0000000..dc835eb --- /dev/null +++ b/src/app/(dashboard)/media/page.tsx @@ -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>("/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) => { + 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
加载中...
; + } + + return ( +
+
+

媒体库

+
+ + +
+
+ +
+ {data?.data.map((item) => ( + + + {item.id} +
+ + +
+
+
+ {format(new Date(item.created_at), "yyyy-MM-dd")} +
+
+ ))} + {data?.data.length === 0 && ( +
+ 暂无图片 +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/tags/page.tsx b/src/app/(dashboard)/tags/page.tsx new file mode 100644 index 0000000..3900c52 --- /dev/null +++ b/src/app/(dashboard)/tags/page.tsx @@ -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(null); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const { data, isLoading } = useQuery({ + queryKey: ["tags"], + queryFn: async () => { + const res = await api.get>("/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
加载中...
; + } + + return ( +
+
+

标签管理

+ +
+ + + + +
+ ); +} diff --git a/src/app/api/dev/set-tenant/route.ts b/src/app/api/dev/set-tenant/route.ts deleted file mode 100644 index 9c9ca84..0000000 --- a/src/app/api/dev/set-tenant/route.ts +++ /dev/null @@ -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 -} - diff --git a/src/app/auth-error/page.tsx b/src/app/auth-error/page.tsx index cd53bd3..8c4f5d7 100644 --- a/src/app/auth-error/page.tsx +++ b/src/app/auth-error/page.tsx @@ -1,24 +1,41 @@ import Link from "next/link"; +import { AlertCircle } from "lucide-react"; export default async function AuthErrorPage({ searchParams, }: { - searchParams: Promise<{ message?: string }> + searchParams: Promise<{ message?: string; error?: string }> }) { const sp = await searchParams; - const message = sp?.message ?? "认证失败" + const message = sp?.message || sp?.error || "认证过程中发生未知错误"; + return ( -
-
-
无法完成登录
-
{message}
-
- +
+
+
+
+ +
+
+ +
+

无法完成登录

+

+ {message} +

+
+ +
+ 返回首页重试
- ) + ); } + diff --git a/src/app/client-required/page.tsx b/src/app/client-required/page.tsx deleted file mode 100644 index e345cc1..0000000 --- a/src/app/client-required/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function ClientRequiredPage() { - return ( -
-
- 缺少 CMS_CLIENT_ID 环境变量,无法发起 SSO 登录跳转。 -
-
- ) -} - diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c67afda..233ffaa 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,16 +1,27 @@ -import type { Metadata } from "next" -import "./globals.css" +import type { Metadata } from "next"; +import { Providers } from "@/components/providers"; +import { Toaster } from "@/components/ui/toaster"; +import "./globals.css"; export const metadata: Metadata = { title: "CMS", description: "CMS Frontend", -} +}; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { return ( - - {children} + + + + {children} + + + - ) + ); } diff --git a/src/app/tenant-required/page.tsx b/src/app/tenant-required/page.tsx deleted file mode 100644 index 047d8df..0000000 --- a/src/app/tenant-required/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export default function TenantRequiredPage() { - return ( -
-
- 缺少 tenantId(x-tenant-id header 或 tenantId cookie),无法跳转统一登录页。 -
- 开发环境可访问:/api/dev/set-tenant?tenantId=你的租户UUID -
-
-
- ) -} diff --git a/src/components/layout/dashboard-layout.tsx b/src/components/layout/dashboard-layout.tsx new file mode 100644 index 0000000..1953be7 --- /dev/null +++ b/src/components/layout/dashboard-layout.tsx @@ -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 {} + +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 ( +
+
+
+

+ CMS 管理平台 +

+
+ {routes.map((route) => ( + + ))} +
+
+
+
+ ); +} + +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 ( +
+
+ {/* Breadcrumbs or Title could go here */} +
+
+ + +
+
+ ); +} diff --git a/src/components/mode-toggle.tsx b/src/components/mode-toggle.tsx new file mode 100644 index 0000000..81474d7 --- /dev/null +++ b/src/components/mode-toggle.tsx @@ -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 ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ) +} diff --git a/src/components/modules/column-dialog.tsx b/src/components/modules/column-dialog.tsx new file mode 100644 index 0000000..ec8eb5b --- /dev/null +++ b/src/components/modules/column-dialog.tsx @@ -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; + +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({ + 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 ( + + + + {column ? "编辑栏目" : "创建栏目"} + + {column ? "修改栏目信息" : "填写新栏目的基本信息"} + + +
+
+
+ +
+ + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+
+
+ +
+ + {errors.slug && ( +

+ {errors.slug.message} +

+ )} +
+
+
+ +
+ +
+
+
+ + +
+
+ + + +
+
+
+ ); +} diff --git a/src/components/modules/columns-table.tsx b/src/components/modules/columns-table.tsx new file mode 100644 index 0000000..d99536e --- /dev/null +++ b/src/components/modules/columns-table.tsx @@ -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 ( + + + + 排序 + 名称 + Slug + 描述 + 创建时间 + 操作 + + + + {data.map((column) => ( + + {column.sort_order} + {column.name} + {column.slug} + {column.description || "-"} + + {format(new Date(column.created_at), "yyyy-MM-dd HH:mm")} + + +
+ + +
+
+
+ ))} + {data.length === 0 && ( + + + 暂无数据 + + + )} +
+
+ ); +} diff --git a/src/components/modules/tag-dialog.tsx b/src/components/modules/tag-dialog.tsx new file mode 100644 index 0000000..34c08ae --- /dev/null +++ b/src/components/modules/tag-dialog.tsx @@ -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; + +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({ + 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 ( + + + + {tag ? "编辑标签" : "创建标签"} + + {tag ? "修改标签信息" : "填写新标签的基本信息"} + + +
+
+
+ +
+ + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+
+
+ +
+ + {errors.slug && ( +

+ {errors.slug.message} +

+ )} +
+
+
+ +
+ + {errors.kind && ( +

+ {errors.kind.message} +

+ )} +
+
+
+ + + +
+
+
+ ); +} diff --git a/src/components/modules/tags-table.tsx b/src/components/modules/tags-table.tsx new file mode 100644 index 0000000..86e0129 --- /dev/null +++ b/src/components/modules/tags-table.tsx @@ -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 ( + + + + 名称 + Slug + 类型 + 创建时间 + 操作 + + + + {data.map((tag) => ( + + {tag.name} + {tag.slug} + + {tag.kind} + + + {format(new Date(tag.created_at), "yyyy-MM-dd HH:mm")} + + +
+ + +
+
+
+ ))} + {data.length === 0 && ( + + + 暂无数据 + + + )} +
+
+ ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx new file mode 100644 index 0000000..0b0cc66 --- /dev/null +++ b/src/components/providers.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..95872ff --- /dev/null +++ b/src/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + }, +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..8d527cf --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..769ff7a --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..184fdaa --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..8e9f0c3 --- /dev/null +++ b/src/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..7f3502f --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..32eba7d --- /dev/null +++ b/src/components/ui/toast.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..e223385 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -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 ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts new file mode 100644 index 0000000..d9423f8 --- /dev/null +++ b/src/components/ui/use-toast.ts @@ -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 + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +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 + +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(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 } diff --git a/src/proxy.ts b/src/proxy.ts index 349c877..1c28d46 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -34,11 +34,7 @@ export function proxy(req: NextRequest) { const currentUrl = req.nextUrl.clone(); const pathname = currentUrl.pathname; - if ( - pathname === "/auth-error" || - pathname === "/tenant-required" || - pathname === "/client-required" - ) { + if (pathname === "/auth-error") { return NextResponse.next(); } @@ -53,13 +49,13 @@ export function proxy(req: NextRequest) { if (code) { if (!cmsServiceBaseUrl) { const url = req.nextUrl.clone(); - url.pathname = "/client-required"; - url.search = ""; + url.pathname = "/auth-error"; + url.search = `?error=${encodeURIComponent("Configuration Error: CMS_SERVICE_BASE_URL is missing")}`; return NextResponse.redirect(url, 302); } const nextUrl = currentUrl.clone(); nextUrl.searchParams.delete("code"); - const callback = `${cmsServiceBaseUrl}/auth/callback?code=${encodeURIComponent( + const callback = `${cmsServiceBaseUrl}/api/v1/auth/callback?code=${encodeURIComponent( code, )}&tenant_id=${encodeURIComponent(tenantId)}&next=${encodeURIComponent(nextUrl.toString())}`; return NextResponse.redirect(callback, 302); @@ -74,7 +70,7 @@ export function proxy(req: NextRequest) { const next = encodeURIComponent(currentUrl.toString()); if (cmsServiceBaseUrl) { // 跳转到 cms-service 的 refresh 接口,由它完成刷新并重定向回来 - const refreshUrl = `${cmsServiceBaseUrl}/auth/refresh?token=${encodeURIComponent( + const refreshUrl = `${cmsServiceBaseUrl}/api/v1/auth/refresh?token=${encodeURIComponent( refreshToken, )}&next=${next}`; return NextResponse.redirect(refreshUrl, 302); @@ -83,26 +79,26 @@ export function proxy(req: NextRequest) { if (!tenantId) { const url = req.nextUrl.clone(); - url.pathname = "/tenant-required"; - url.search = ""; + url.pathname = "/auth-error"; + 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); } if (!clientId) { const url = req.nextUrl.clone(); - url.pathname = "/client-required"; - url.search = ""; + url.pathname = "/auth-error"; + url.search = `?error=${encodeURIComponent("Configuration Error: CMS_CLIENT_ID is missing")}`; return NextResponse.redirect(url, 302); } const next = encodeURIComponent(currentUrl.toString()); if (!cmsServiceBaseUrl) { const url = req.nextUrl.clone(); - url.pathname = "/client-required"; - url.search = ""; + url.pathname = "/auth-error"; + url.search = `?error=${encodeURIComponent("Configuration Error: CMS_SERVICE_BASE_URL is missing")}`; return NextResponse.redirect(url, 302); } - const callback = `${cmsServiceBaseUrl}/auth/callback?tenant_id=${encodeURIComponent( + const callback = `${cmsServiceBaseUrl}/api/v1/auth/callback?tenant_id=${encodeURIComponent( tenantId, )}&next=${next}`; const loginUrl = `${process.env.IAM_FRONT_BASE_URL}/login?clientId=${encodeURIComponent( diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..b058cac --- /dev/null +++ b/src/services/api.ts @@ -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; diff --git a/src/services/types.ts b/src/services/types.ts new file mode 100644 index 0000000..df17161 --- /dev/null +++ b/src/services/types.ts @@ -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 { + data: T[]; + total: number; + page: number; + page_size: number; + total_pages: number; +}