feat(auth): login/get/logout

This commit is contained in:
2026-02-11 10:54:43 +08:00
parent 1444cbc665
commit e17242eec3
19 changed files with 1808 additions and 302 deletions

View File

@@ -6,10 +6,12 @@
"dev": "next dev --turbopack -p 6031",
"build": "next build",
"start": "next start -p 6031",
"gen:types": "openapi-typescript http://localhost:5031/scalar/openapi.json -o src/services/types.d.ts",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -43,6 +45,7 @@
"autoprefixer": "^10.4.20",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.6",
"openapi-typescript": "^7.12.0",
"postcss": "^8.5.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.7.3",

View File

@@ -1,12 +1,12 @@
"use client";
"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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import api from "@/services/api"
import { PagedArticle } 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,
@@ -14,41 +14,43 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
export default function ArticlesPage() {
const queryClient = useQueryClient();
const { toast } = useToast();
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;
// 使用生成的具体分页类型 PagedArticle
return api.get<PagedArticle>("/articles")
},
});
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/articles/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["articles"] });
toast({ title: "删除成功" });
queryClient.invalidateQueries({ queryKey: ["articles"] })
toast({ title: "删除成功" })
},
});
})
const handleDelete = (id: string) => {
if (confirm("确定要删除吗?")) {
deleteMutation.mutate(id);
deleteMutation.mutate(id)
}
};
}
if (isLoading) {
return (
<div className="p-8 text-center text-muted-foreground">...</div>
);
)
}
const articles = data?.items || []
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -70,7 +72,7 @@ export default function ArticlesPage() {
</TableRow>
</TableHeader>
<TableBody>
{data?.data.map((article) => (
{articles.map((article) => (
<TableRow key={article.id}>
<TableCell className="font-medium">{article.title}</TableCell>
<TableCell>{article.slug}</TableCell>
@@ -108,7 +110,7 @@ export default function ArticlesPage() {
</TableCell>
</TableRow>
))}
{data?.data.length === 0 && (
{articles.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
@@ -118,5 +120,5 @@ export default function ArticlesPage() {
</TableBody>
</Table>
</div>
);
)
}

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/services/api";
import { Column, PaginatedResponse } from "@/services/types";
import { Column, PagedColumn, CreateColumnRequest } from "@/services/types";
import { ColumnsTable } from "@/components/modules/columns-table";
import { ColumnDialog } from "@/components/modules/column-dialog";
import { Button } from "@/components/ui/button";
@@ -19,13 +19,12 @@ export default function ColumnsPage() {
const { data, isLoading } = useQuery({
queryKey: ["columns"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Column>>("/columns");
return res.data;
return api.get<PagedColumn>("/columns");
},
});
const createMutation = useMutation({
mutationFn: (values: any) => api.post("/columns", values),
mutationFn: (values: CreateColumnRequest) => api.post("/columns", values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["columns"] });
setOpen(false);
@@ -34,8 +33,13 @@ export default function ColumnsPage() {
});
const updateMutation = useMutation({
mutationFn: ({ id, values }: { id: string; values: any }) =>
api.patch(`/columns/${id}`, values),
mutationFn: ({
id,
values,
}: {
id: string;
values: Partial<CreateColumnRequest>;
}) => api.patch(`/columns/${id}`, values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["columns"] });
setOpen(false);
@@ -52,7 +56,7 @@ export default function ColumnsPage() {
},
});
const handleSubmit = (values: any) => {
const handleSubmit = (values: CreateColumnRequest) => {
if (editingColumn) {
updateMutation.mutate({ id: editingColumn.id, values });
} else {
@@ -60,21 +64,25 @@ export default function ColumnsPage() {
}
};
const handleEdit = (column: Column) => {
setEditingColumn(column);
setOpen(true);
};
const handleDelete = (id: string) => {
if (confirm("确定要删除吗?")) {
deleteMutation.mutate(id);
}
};
const handleEdit = (column: Column) => {
setEditingColumn(column);
setOpen(true);
};
if (isLoading) {
return <div className="p-8 text-center text-muted-foreground">...</div>;
return (
<div className="p-8 text-center text-muted-foreground">...</div>
);
}
const columns = data?.items || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -90,7 +98,7 @@ export default function ColumnsPage() {
</div>
<ColumnsTable
data={data?.data || []}
data={columns}
onEdit={handleEdit}
onDelete={handleDelete}
/>

View File

@@ -3,70 +3,79 @@
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 {
PagedArticle,
PagedColumn,
PagedTag,
PagedMedia,
} 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 res = await api.get<PagedArticle>("/articles");
return res.items;
},
});
const { data: columns } = useQuery({
queryKey: ["columns"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Column>>("/columns");
return res.data;
}
const res = await api.get<PagedColumn>("/columns");
return res.items;
},
});
const { data: tags } = useQuery({
queryKey: ["tags"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Tag>>("/tags");
return res.data;
}
const res = await api.get<PagedTag>("/tags");
return res.items;
},
});
const { data: media } = useQuery({
queryKey: ["media"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Media>>("/media");
return res.data;
}
const res = await api.get<PagedMedia>("/media");
return res.items;
},
});
const isLoading = [articles, columns, tags, media].every((x) => x === undefined);
const isLoading = [articles, columns, tags, media].every(
(x) => x === undefined,
);
if (isLoading) {
return <div className="p-8 text-center text-muted-foreground">...</div>;
return (
<div className="p-8 text-center text-muted-foreground">...</div>
);
}
const stats = [
{
title: "文章总数",
value: articles?.total || 0,
value: articles?.length || 0,
icon: FileText,
description: "已发布和草稿状态的文章",
},
{
title: "栏目数量",
value: columns?.total || 0,
value: columns?.length || 0,
icon: PanelLeft,
description: "内容分类栏目",
},
{
title: "标签数量",
value: tags?.total || 0,
value: tags?.length || 0,
icon: Tags,
description: "用于内容标记",
},
{
title: "媒体资源",
value: media?.total || 0,
value: media?.length || 0,
icon: ImageIcon,
description: "图片和文件资源",
},

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/services/api";
import { Media, PaginatedResponse } from "@/services/types";
import { Media, PagedMedia } from "@/services/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Upload, Trash2, Copy } from "lucide-react";
@@ -20,8 +20,7 @@ export default function MediaPage() {
const { data, isLoading } = useQuery({
queryKey: ["media"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Media>>("/media");
return res.data;
return api.get<PagedMedia>("/media");
},
});
@@ -41,9 +40,9 @@ export default function MediaPage() {
setUploading(false);
},
onError: () => {
setUploading(false);
toast({ title: "上传失败", variant: "destructive" });
}
setUploading(false);
toast({ title: "上传失败", variant: "destructive" });
},
});
const deleteMutation = useMutation({
@@ -73,9 +72,13 @@ export default function MediaPage() {
};
if (isLoading) {
return <div className="p-8 text-center text-muted-foreground">...</div>;
return (
<div className="p-8 text-center text-muted-foreground">...</div>
);
}
const mediaItems = data?.items || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -89,14 +92,14 @@ export default function MediaPage() {
disabled={uploading}
/>
<Button disabled={uploading}>
<Upload className="mr-2 h-4 w-4" />
<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) => (
{mediaItems.map((item: Media) => (
<Card key={item.id} className="overflow-hidden group">
<CardContent className="p-0 relative aspect-square">
<Image
@@ -124,14 +127,14 @@ export default function MediaPage() {
</div>
</CardContent>
<div className="p-2 text-xs text-muted-foreground truncate">
{format(new Date(item.created_at), "yyyy-MM-dd")}
{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>
{mediaItems.length === 0 && (
<div className="col-span-full text-center py-10 text-muted-foreground">
</div>
)}
</div>
</div>

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/services/api";
import { Tag, PaginatedResponse } from "@/services/types";
import { Tag, PagedTag, CreateTagRequest } from "@/services/types";
import { TagsTable } from "@/components/modules/tags-table";
import { TagDialog } from "@/components/modules/tag-dialog";
import { Button } from "@/components/ui/button";
@@ -19,13 +19,12 @@ export default function TagsPage() {
const { data, isLoading } = useQuery({
queryKey: ["tags"],
queryFn: async () => {
const res = await api.get<PaginatedResponse<Tag>>("/tags");
return res.data;
return api.get<PagedTag>("/tags");
},
});
const createMutation = useMutation({
mutationFn: (values: any) => api.post("/tags", values),
mutationFn: (values: CreateTagRequest) => api.post("/tags", values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
setOpen(false);
@@ -34,8 +33,13 @@ export default function TagsPage() {
});
const updateMutation = useMutation({
mutationFn: ({ id, values }: { id: string; values: any }) =>
api.patch(`/tags/${id}`, values),
mutationFn: ({
id,
values,
}: {
id: string;
values: Partial<CreateTagRequest>;
}) => api.patch(`/tags/${id}`, values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
setOpen(false);
@@ -52,7 +56,7 @@ export default function TagsPage() {
},
});
const handleSubmit = (values: any) => {
const handleSubmit = (values: CreateTagRequest) => {
if (editingTag) {
updateMutation.mutate({ id: editingTag.id, values });
} else {
@@ -60,21 +64,25 @@ export default function TagsPage() {
}
};
const handleEdit = (tag: Tag) => {
setEditingTag(tag);
setOpen(true);
};
const handleDelete = (id: string) => {
if (confirm("确定要删除吗?")) {
deleteMutation.mutate(id);
}
};
const handleEdit = (tag: Tag) => {
setEditingTag(tag);
setOpen(true);
};
if (isLoading) {
return <div className="p-8 text-center text-muted-foreground">...</div>;
return (
<div className="p-8 text-center text-muted-foreground">...</div>
);
}
const tags = data?.items || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -89,11 +97,7 @@ export default function TagsPage() {
</Button>
</div>
<TagsTable
data={data?.data || []}
onEdit={handleEdit}
onDelete={handleDelete}
/>
<TagsTable data={tags} onEdit={handleEdit} onDelete={handleDelete} />
<TagDialog
open={open}

View File

@@ -20,7 +20,7 @@ export default async function AuthErrorPage({
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-sm text-muted-foreground break-words">
<p className="text-sm text-muted-foreground wrap-break-word">
{message}
</p>
</div>

View File

@@ -92,28 +92,75 @@
}
}
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.75rem;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,23 +1,28 @@
import { cookies } from "next/headers"
// import { cookies } from "next/headers"
export default async function Home() {
const cookieStore = await cookies()
const tenantId = cookieStore.get("tenantId")?.value ?? ""
const userId = cookieStore.get("userId")?.value ?? ""
// export default async function Home() {
// const cookieStore = await cookies()
// const tenantId = cookieStore.get("tenantId")?.value ?? ""
// const userId = cookieStore.get("userId")?.value ?? ""
return (
<main className="min-h-screen p-6">
<div className="mx-auto max-w-3xl space-y-4">
<div className="text-2xl font-semibold">CMS</div>
<div className="rounded-md border border-border p-4 text-sm">
<div>tenantId: {tenantId || "-"}</div>
<div>userId: {userId || "-"}</div>
</div>
<div className="text-sm text-muted-foreground">
</div>
</div>
</main>
)
// return (
// <main className="min-h-screen p-6">
// <div className="mx-auto max-w-3xl space-y-4">
// <div className="text-2xl font-semibold">CMS</div>
// <div className="rounded-md border border-border p-4 text-sm">
// <div>tenantId: {tenantId || "-"}</div>
// <div>userId: {userId || "-"}</div>
// </div>
// <div className="text-sm text-muted-foreground">
// 如果未登录,会被中间件重定向到统一登录页。
// </div>
// </div>
// </main>
// )
// }
import { redirect } from "next/navigation"
export default function Home() {
redirect("/dashboard")
}

View File

@@ -1,25 +1,24 @@
"use client";
"use client"
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
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";
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { ModeToggle } from "@/components/mode-toggle"
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {}
type SidebarProps = React.HTMLAttributes<HTMLDivElement>
export function Sidebar({ className }: SidebarProps) {
const pathname = usePathname();
const pathname = usePathname()
const routes = [
{
@@ -34,6 +33,12 @@ export function Sidebar({ className }: SidebarProps) {
href: "/columns",
active: pathname.startsWith("/columns"),
},
{
label: "文章管理",
icon: FileText,
href: "/articles",
active: pathname.startsWith("/articles"),
},
{
label: "标签管理",
icon: Tags,
@@ -46,13 +51,7 @@ export function Sidebar({ className }: SidebarProps) {
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)}>
@@ -79,30 +78,33 @@ export function Sidebar({ className }: SidebarProps) {
</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
await fetch("/api/cms/auth/logout", { method: "POST" })
window.location.href = "/"
} catch (e) {
console.error(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-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
variant="ghost"
size="icon"
onClick={handleLogout}
title="退出登录"
>
<LogOut className="h-5 w-5" />
</Button>
</div>
</header>
);
)
}

View File

@@ -5,30 +5,31 @@ import {
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";
} 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 { z } from "zod"
import { Column, CreateColumnRequest } 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(),
description: z.string().optional().nullable(),
sort_order: z.number().int().default(0),
});
parent_id: z.string().optional().nullable(),
})
type ColumnFormValues = z.infer<typeof columnSchema>;
type ColumnFormValues = z.input<typeof columnSchema>
interface ColumnDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
column?: Column | null;
onSubmit: (values: ColumnFormValues) => void;
open: boolean
onOpenChange: (open: boolean) => void
column?: Column | null
onSubmit: (values: CreateColumnRequest) => Promise<void> | void
}
export function ColumnDialog({
@@ -49,8 +50,9 @@ export function ColumnDialog({
slug: "",
description: "",
sort_order: 0,
parent_id: null,
},
});
})
useEffect(() => {
if (open) {
@@ -59,9 +61,22 @@ export function ColumnDialog({
slug: column?.slug || "",
description: column?.description || "",
sort_order: column?.sort_order || 0,
});
parent_id: column?.parent_id || null,
})
}
}, [open, column, reset]);
}, [open, column, reset])
const onFormSubmit = async (data: ColumnFormValues) => {
// 转换为 API 需要的类型
const requestData: CreateColumnRequest = {
name: data.name,
slug: data.slug,
description: data.description || null,
sort_order: data.sort_order ?? 0,
parent_id: data.parent_id ?? null,
}
await onSubmit(requestData)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -72,7 +87,7 @@ export function ColumnDialog({
{column ? "修改栏目信息" : "填写新栏目的基本信息"}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
@@ -131,5 +146,5 @@ export function ColumnDialog({
</form>
</DialogContent>
</Dialog>
);
)
}

View File

@@ -1,9 +1,7 @@
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,

View File

@@ -5,29 +5,29 @@ import {
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";
} 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 { z } from "zod"
import { Tag, CreateTagRequest } 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>;
type TagFormValues = z.input<typeof tagSchema>
interface TagDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tag?: Tag | null;
onSubmit: (values: TagFormValues) => void;
open: boolean
onOpenChange: (open: boolean) => void
tag?: Tag | null
onSubmit: (values: CreateTagRequest) => Promise<void> | void
}
export function TagDialog({
@@ -48,7 +48,7 @@ export function TagDialog({
slug: "",
kind: "default",
},
});
})
useEffect(() => {
if (open) {
@@ -56,9 +56,17 @@ export function TagDialog({
name: tag?.name || "",
slug: tag?.slug || "",
kind: tag?.kind || "default",
});
})
}
}, [open, tag, reset]);
}, [open, tag, reset])
const onFormSubmit = async (data: TagFormValues) => {
await onSubmit({
name: data.name,
slug: data.slug,
kind: data.kind ?? "default",
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -69,7 +77,7 @@ export function TagDialog({
{tag ? "修改标签信息" : "填写新标签的基本信息"}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
@@ -119,5 +127,5 @@ export function TagDialog({
</form>
</DialogContent>
</Dialog>
);
)
}

View File

@@ -1,7 +1,6 @@
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"
@@ -15,7 +14,7 @@ const ToastViewport = React.forwardRef<
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
className,
)}
{...props}
/>
@@ -35,7 +34,7 @@ const toastVariants = cva(
defaultVariants: {
variant: "default",
},
}
},
)
const Toast = React.forwardRef<
@@ -61,7 +60,7 @@ const ToastAction = React.forwardRef<
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
className,
)}
{...props}
/>
@@ -76,7 +75,7 @@ const ToastClose = React.forwardRef<
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
className,
)}
toast-close=""
{...props}

View File

@@ -1,27 +1,329 @@
import axios from "axios";
// --- 类型定义 ---
const api = axios.create({
baseURL: "/api/cms",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
interface FetchOptions extends RequestInit {
params?: Record<string, string | number | boolean | undefined | null>
responseType?: "json" | "text" | "blob" | "arrayBuffer"
onUploadProgress?: (progress: number) => void // 0-100
timeout?: number // 毫秒
}
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
export class ApiError extends Error {
status: number
statusText: string
data: unknown
constructor(status: number, statusText: string, data: unknown) {
super(`API Error ${status}: ${statusText}`)
this.name = "ApiError"
this.status = status
this.statusText = statusText
this.data = data
}
}
const BASE_URL = "/api/cms"
// --- 辅助函数 ---
/**
* 智能序列化查询参数
*/
function serializeParams(params: Record<string, unknown>): string {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
value.forEach((v) => searchParams.append(key, String(v)))
} else {
searchParams.append(key, String(value))
}
}
return Promise.reject(error);
}
);
})
return searchParams.toString()
}
export default api;
/**
* 带超时的 Fetch
*/
async function fetchWithTimeout(
url: string,
options: RequestInit,
timeout = 10000,
): Promise<Response> {
const controller = new AbortController()
const id = setTimeout(() => controller.abort(), timeout)
// 如果外部传入了 signal我们需要合并 abort 逻辑
if (options.signal) {
options.signal.addEventListener("abort", () => {
clearTimeout(id)
controller.abort()
})
}
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
})
clearTimeout(id)
return response
} catch (error) {
clearTimeout(id)
throw error
}
}
// --- 核心请求函数 ---
async function request<T = unknown>(
endpoint: string,
options: FetchOptions = {},
): Promise<T> {
const {
params,
headers,
responseType = "json",
onUploadProgress,
timeout = 15000,
...rest
} = options
let url = `${BASE_URL}${endpoint}`
if (params) {
const queryString = serializeParams(params)
if (queryString) {
url += `?${queryString}`
}
}
// 特殊处理:如果有上传进度需求,回退到 XHR
if (onUploadProgress && rest.method !== "GET" && rest.method !== "HEAD") {
return xhrRequest<T>(url, options)
}
const defaultHeaders: Record<string, string> = {}
// 自动从浏览器环境获取 Cookie 中的 accessToken 并附加到 Header
// 注意:服务端渲染时通常需要手动传入 headers但在客户端组件中可以直接读取
if (typeof document !== "undefined") {
const getCookie = (name: string) => {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) return parts.pop()?.split(";").shift()
}
const token = getCookie("accessToken")
if (token) {
defaultHeaders["Authorization"] = `Bearer ${token}`
}
const tenantId = getCookie("tenantId")
if (tenantId) {
defaultHeaders["X-Tenant-ID"] = tenantId
}
}
// 自动处理 Content-Type
// 注意:当 body 是 FormData 时Fetch 会自动设置 Content-Type 为 multipart/form-data 并带上 boundary
// 所以这里明确:如果是 FormData不要手动设置 Content-Type
if (
!(rest.body instanceof FormData) &&
!(rest.body instanceof Blob) &&
!(rest.body instanceof ArrayBuffer)
) {
defaultHeaders["Content-Type"] = "application/json"
}
const response = await fetchWithTimeout(
url,
{
...rest,
headers: {
...defaultHeaders,
...headers,
},
},
timeout,
)
// 统一错误处理
if (!response.ok) {
// 处理 401 认证失效
if (response.status === 401) {
if (typeof window !== "undefined") {
// 这里通常不需要做跳转,因为 Next.js Middleware 已经处理了刷新
// 如果到了这里,说明刷新失败或 Token 无效,可以根据业务需求决定是否跳转
// window.location.href = "/auth-error";
}
}
// 尝试解析错误信息
let errorData
const contentType = response.headers.get("content-type")
try {
if (contentType && contentType.includes("application/json")) {
errorData = await response.json()
} else {
errorData = await response.text()
}
} catch {
errorData = response.statusText
}
throw new ApiError(response.status, response.statusText, errorData)
}
// 处理 204 No Content
if (response.status === 204) {
return {} as T
}
// 根据 responseType 处理响应数据
try {
switch (responseType) {
case "json":
return await response.json()
case "text":
return (await response.text()) as unknown as T
case "blob":
return (await response.blob()) as unknown as T
case "arrayBuffer":
return (await response.arrayBuffer()) as unknown as T
default:
return await response.json()
}
} catch (error) {
// JSON 解析失败但响应是 OK 的情况
console.warn("Response parsing failed:", error)
return {} as T
}
}
/**
* XHR 请求实现(用于支持上传进度)
*/
function xhrRequest<T>(url: string, options: FetchOptions): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(options.method || "GET", url)
if (options.responseType === "arrayBuffer") {
xhr.responseType = "arraybuffer"
} else {
xhr.responseType =
options.responseType === "json"
? "json"
: (options.responseType as XMLHttpRequestResponseType) || "text"
}
xhr.timeout = options.timeout || 15000
// 设置 Headers
const headers = (options.headers as Record<string, string>) || {}
if (!(options.body instanceof FormData)) {
headers["Content-Type"] = "application/json"
}
Object.entries(headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value)
})
// 进度监听
if (options.onUploadProgress && xhr.upload) {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100
options.onUploadProgress!(percentComplete)
}
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response)
} else {
reject(new ApiError(xhr.status, xhr.statusText, xhr.response))
}
}
xhr.onerror = () => reject(new ApiError(0, "Network Error", null))
xhr.ontimeout = () => reject(new ApiError(408, "Timeout", null))
if (options.signal) {
options.signal.onabort = () => {
xhr.abort()
reject(new DOMException("Aborted", "AbortError"))
}
}
xhr.send(options.body as XMLHttpRequestBodyInit)
})
}
// --- API 导出 ---
const api = {
get: <T>(url: string, options?: FetchOptions) =>
request<T>(url, { ...options, method: "GET" }),
post: <T>(url: string, data?: unknown, options?: FetchOptions) =>
request<T>(url, {
...options,
method: "POST",
body: data instanceof FormData ? data : JSON.stringify(data),
}),
put: <T>(url: string, data?: unknown, options?: FetchOptions) =>
request<T>(url, {
...options,
method: "PUT",
body: data instanceof FormData ? data : JSON.stringify(data),
}),
patch: <T>(url: string, data?: unknown, options?: FetchOptions) =>
request<T>(url, {
...options,
method: "PATCH",
body: data instanceof FormData ? data : JSON.stringify(data),
}),
delete: <T>(url: string, options?: FetchOptions) =>
request<T>(url, { ...options, method: "DELETE" }),
// 专用上传方法
upload: <T>(
url: string,
fileOrFormData: File | FormData,
onProgress?: (percent: number) => void,
) => {
const formData =
fileOrFormData instanceof File
? (() => {
const fd = new FormData()
fd.append("file", fileOrFormData)
return fd
})()
: fileOrFormData
return request<T>(url, {
method: "POST",
body: formData,
onUploadProgress: onProgress,
})
},
// 专用下载方法
download: async (url: string, filename?: string) => {
const blob = await request<Blob>(url, {
method: "GET",
responseType: "blob",
})
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = downloadUrl
if (filename) link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(downloadUrl)
},
}
export default api

1138
src/services/types.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,24 @@
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;
}
import { components } from "./types.d"
export interface Tag {
tenant_id: string;
id: string;
kind: string;
name: string;
slug: string;
created_at: string;
updated_at: string;
}
export type Column = components["schemas"]["Column"]
export type CreateColumnRequest = components["schemas"]["CreateColumnRequest"]
export type UpdateColumnRequest = components["schemas"]["UpdateColumnRequest"]
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 type Tag = components["schemas"]["Tag"]
export type CreateTagRequest = components["schemas"]["CreateTagRequest"]
export type UpdateTagRequest = components["schemas"]["UpdateTagRequest"]
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 type Media = components["schemas"]["Media"]
export type CreateMediaRequest = components["schemas"]["CreateMediaRequest"]
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 type Article = components["schemas"]["Article"]
export type CreateArticleRequest = components["schemas"]["CreateArticleRequest"]
export type UpdateArticleRequest = components["schemas"]["UpdateArticleRequest"]
export type ArticleVersion = components["schemas"]["ArticleVersion"]
export type ArticleWithTags = components["schemas"]["ArticleWithTags"]
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export type PagedArticle = components["schemas"]["Paged_Article"]
export type PagedColumn = components["schemas"]["Paged_Column"]
export type PagedTag = components["schemas"]["Paged_Tag"]
export type PagedMedia = components["schemas"]["Paged_Media"]
export type PagedArticleVersion = components["schemas"]["Paged_ArticleVersion"]

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "es2022"],
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
@@ -20,15 +24,20 @@
],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": [
"src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules",
".next"
]
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long