feat(auth): login/get/logout
This commit is contained in:
@@ -6,10 +6,12 @@
|
|||||||
"dev": "next dev --turbopack -p 6031",
|
"dev": "next dev --turbopack -p 6031",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 6031",
|
"start": "next start -p 6031",
|
||||||
|
"gen:types": "openapi-typescript http://localhost:5031/scalar/openapi.json -o src/services/types.d.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.0",
|
"@radix-ui/react-checkbox": "^1.3.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
|
"openapi-typescript": "^7.12.0",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import api from "@/services/api";
|
import api from "@/services/api"
|
||||||
import { Article, PaginatedResponse } from "@/services/types";
|
import { PagedArticle } from "@/services/types"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Plus, Edit, Trash2 } from "lucide-react";
|
import { Plus, Edit, Trash2 } from "lucide-react"
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -14,41 +14,43 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table"
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
export default function ArticlesPage() {
|
export default function ArticlesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
const { toast } = useToast();
|
const { toast } = useToast()
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["articles"],
|
queryKey: ["articles"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get<PaginatedResponse<Article>>("/articles");
|
// 使用生成的具体分页类型 PagedArticle
|
||||||
return res.data;
|
return api.get<PagedArticle>("/articles")
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/articles/${id}`),
|
mutationFn: (id: string) => api.delete(`/articles/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["articles"] });
|
queryClient.invalidateQueries({ queryKey: ["articles"] })
|
||||||
toast({ title: "删除成功" });
|
toast({ title: "删除成功" })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
if (confirm("确定要删除吗?")) {
|
if (confirm("确定要删除吗?")) {
|
||||||
deleteMutation.mutate(id);
|
deleteMutation.mutate(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-muted-foreground">加载中...</div>
|
<div className="p-8 text-center text-muted-foreground">加载中...</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const articles = data?.items || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -70,7 +72,7 @@ export default function ArticlesPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.data.map((article) => (
|
{articles.map((article) => (
|
||||||
<TableRow key={article.id}>
|
<TableRow key={article.id}>
|
||||||
<TableCell className="font-medium">{article.title}</TableCell>
|
<TableCell className="font-medium">{article.title}</TableCell>
|
||||||
<TableCell>{article.slug}</TableCell>
|
<TableCell>{article.slug}</TableCell>
|
||||||
@@ -108,7 +110,7 @@ export default function ArticlesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{data?.data.length === 0 && (
|
{articles.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="h-24 text-center">
|
<TableCell colSpan={6} className="h-24 text-center">
|
||||||
暂无数据
|
暂无数据
|
||||||
@@ -118,5 +120,5 @@ export default function ArticlesPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import api from "@/services/api";
|
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 { ColumnsTable } from "@/components/modules/columns-table";
|
||||||
import { ColumnDialog } from "@/components/modules/column-dialog";
|
import { ColumnDialog } from "@/components/modules/column-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -19,13 +19,12 @@ export default function ColumnsPage() {
|
|||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["columns"],
|
queryKey: ["columns"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get<PaginatedResponse<Column>>("/columns");
|
return api.get<PagedColumn>("/columns");
|
||||||
return res.data;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (values: any) => api.post("/columns", values),
|
mutationFn: (values: CreateColumnRequest) => api.post("/columns", values),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["columns"] });
|
queryClient.invalidateQueries({ queryKey: ["columns"] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -34,8 +33,13 @@ export default function ColumnsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, values }: { id: string; values: any }) =>
|
mutationFn: ({
|
||||||
api.patch(`/columns/${id}`, values),
|
id,
|
||||||
|
values,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
values: Partial<CreateColumnRequest>;
|
||||||
|
}) => api.patch(`/columns/${id}`, values),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["columns"] });
|
queryClient.invalidateQueries({ queryKey: ["columns"] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -52,7 +56,7 @@ export default function ColumnsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: any) => {
|
const handleSubmit = (values: CreateColumnRequest) => {
|
||||||
if (editingColumn) {
|
if (editingColumn) {
|
||||||
updateMutation.mutate({ id: editingColumn.id, values });
|
updateMutation.mutate({ id: editingColumn.id, values });
|
||||||
} else {
|
} else {
|
||||||
@@ -60,21 +64,25 @@ export default function ColumnsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (column: Column) => {
|
|
||||||
setEditingColumn(column);
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
if (confirm("确定要删除吗?")) {
|
if (confirm("确定要删除吗?")) {
|
||||||
deleteMutation.mutate(id);
|
deleteMutation.mutate(id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (column: Column) => {
|
||||||
|
setEditingColumn(column);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -90,7 +98,7 @@ export default function ColumnsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ColumnsTable
|
<ColumnsTable
|
||||||
data={data?.data || []}
|
data={columns}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,70 +3,79 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import api from "@/services/api";
|
import api from "@/services/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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";
|
import { FileText, PanelLeft, Tags, Image as ImageIcon } from "lucide-react";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { data: articles } = useQuery({
|
const { data: articles } = useQuery({
|
||||||
queryKey: ["articles"],
|
queryKey: ["articles"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get<PaginatedResponse<Article>>("/articles");
|
const res = await api.get<PagedArticle>("/articles");
|
||||||
return res.data;
|
return res.items;
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: columns } = useQuery({
|
const { data: columns } = useQuery({
|
||||||
queryKey: ["columns"],
|
queryKey: ["columns"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get<PaginatedResponse<Column>>("/columns");
|
const res = await api.get<PagedColumn>("/columns");
|
||||||
return res.data;
|
return res.items;
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: tags } = useQuery({
|
const { data: tags } = useQuery({
|
||||||
queryKey: ["tags"],
|
queryKey: ["tags"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get<PaginatedResponse<Tag>>("/tags");
|
const res = await api.get<PagedTag>("/tags");
|
||||||
return res.data;
|
return res.items;
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: media } = useQuery({
|
const { data: media } = useQuery({
|
||||||
queryKey: ["media"],
|
queryKey: ["media"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get<PaginatedResponse<Media>>("/media");
|
const res = await api.get<PagedMedia>("/media");
|
||||||
return res.data;
|
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) {
|
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 = [
|
const stats = [
|
||||||
{
|
{
|
||||||
title: "文章总数",
|
title: "文章总数",
|
||||||
value: articles?.total || 0,
|
value: articles?.length || 0,
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
description: "已发布和草稿状态的文章",
|
description: "已发布和草稿状态的文章",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "栏目数量",
|
title: "栏目数量",
|
||||||
value: columns?.total || 0,
|
value: columns?.length || 0,
|
||||||
icon: PanelLeft,
|
icon: PanelLeft,
|
||||||
description: "内容分类栏目",
|
description: "内容分类栏目",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "标签数量",
|
title: "标签数量",
|
||||||
value: tags?.total || 0,
|
value: tags?.length || 0,
|
||||||
icon: Tags,
|
icon: Tags,
|
||||||
description: "用于内容标记",
|
description: "用于内容标记",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "媒体资源",
|
title: "媒体资源",
|
||||||
value: media?.total || 0,
|
value: media?.length || 0,
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
description: "图片和文件资源",
|
description: "图片和文件资源",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import api from "@/services/api";
|
import api from "@/services/api";
|
||||||
import { Media, PaginatedResponse } from "@/services/types";
|
import { Media, PagedMedia } from "@/services/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Upload, Trash2, Copy } from "lucide-react";
|
import { Upload, Trash2, Copy } from "lucide-react";
|
||||||
@@ -20,8 +20,7 @@ export default function MediaPage() {
|
|||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["media"],
|
queryKey: ["media"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get<PaginatedResponse<Media>>("/media");
|
return api.get<PagedMedia>("/media");
|
||||||
return res.data;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ export default function MediaPage() {
|
|||||||
onError: () => {
|
onError: () => {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
toast({ title: "上传失败", variant: "destructive" });
|
toast({ title: "上传失败", variant: "destructive" });
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -73,9 +72,13 @@ export default function MediaPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -96,7 +99,7 @@ export default function MediaPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<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">
|
<Card key={item.id} className="overflow-hidden group">
|
||||||
<CardContent className="p-0 relative aspect-square">
|
<CardContent className="p-0 relative aspect-square">
|
||||||
<Image
|
<Image
|
||||||
@@ -128,7 +131,7 @@ export default function MediaPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
{data?.data.length === 0 && (
|
{mediaItems.length === 0 && (
|
||||||
<div className="col-span-full text-center py-10 text-muted-foreground">
|
<div className="col-span-full text-center py-10 text-muted-foreground">
|
||||||
暂无图片
|
暂无图片
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import api from "@/services/api";
|
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 { TagsTable } from "@/components/modules/tags-table";
|
||||||
import { TagDialog } from "@/components/modules/tag-dialog";
|
import { TagDialog } from "@/components/modules/tag-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -19,13 +19,12 @@ export default function TagsPage() {
|
|||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["tags"],
|
queryKey: ["tags"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.get<PaginatedResponse<Tag>>("/tags");
|
return api.get<PagedTag>("/tags");
|
||||||
return res.data;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (values: any) => api.post("/tags", values),
|
mutationFn: (values: CreateTagRequest) => api.post("/tags", values),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -34,8 +33,13 @@ export default function TagsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, values }: { id: string; values: any }) =>
|
mutationFn: ({
|
||||||
api.patch(`/tags/${id}`, values),
|
id,
|
||||||
|
values,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
values: Partial<CreateTagRequest>;
|
||||||
|
}) => api.patch(`/tags/${id}`, values),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -52,7 +56,7 @@ export default function TagsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: any) => {
|
const handleSubmit = (values: CreateTagRequest) => {
|
||||||
if (editingTag) {
|
if (editingTag) {
|
||||||
updateMutation.mutate({ id: editingTag.id, values });
|
updateMutation.mutate({ id: editingTag.id, values });
|
||||||
} else {
|
} else {
|
||||||
@@ -60,21 +64,25 @@ export default function TagsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (tag: Tag) => {
|
|
||||||
setEditingTag(tag);
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
if (confirm("确定要删除吗?")) {
|
if (confirm("确定要删除吗?")) {
|
||||||
deleteMutation.mutate(id);
|
deleteMutation.mutate(id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (tag: Tag) => {
|
||||||
|
setEditingTag(tag);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -89,11 +97,7 @@ export default function TagsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TagsTable
|
<TagsTable data={tags} onEdit={handleEdit} onDelete={handleDelete} />
|
||||||
data={data?.data || []}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TagDialog
|
<TagDialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default async function AuthErrorPage({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">无法完成登录</h1>
|
<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}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,28 +92,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--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: 222.2 47.4% 11.2%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 210 40% 96.1%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 210 40% 96.1%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 222.2 84% 4.9%;
|
||||||
--radius: 0.75rem;
|
|
||||||
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.dark {
|
||||||
background: hsl(var(--background));
|
--background: 222.2 84% 4.9%;
|
||||||
color: hsl(var(--foreground));
|
--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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { cookies } from "next/headers"
|
// import { cookies } from "next/headers"
|
||||||
|
|
||||||
export default async function Home() {
|
// export default async function Home() {
|
||||||
const cookieStore = await cookies()
|
// const cookieStore = await cookies()
|
||||||
const tenantId = cookieStore.get("tenantId")?.value ?? ""
|
// const tenantId = cookieStore.get("tenantId")?.value ?? ""
|
||||||
const userId = cookieStore.get("userId")?.value ?? ""
|
// const userId = cookieStore.get("userId")?.value ?? ""
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<main className="min-h-screen p-6">
|
// <main className="min-h-screen p-6">
|
||||||
<div className="mx-auto max-w-3xl space-y-4">
|
// <div className="mx-auto max-w-3xl space-y-4">
|
||||||
<div className="text-2xl font-semibold">CMS</div>
|
// <div className="text-2xl font-semibold">CMS</div>
|
||||||
<div className="rounded-md border border-border p-4 text-sm">
|
// <div className="rounded-md border border-border p-4 text-sm">
|
||||||
<div>tenantId: {tenantId || "-"}</div>
|
// <div>tenantId: {tenantId || "-"}</div>
|
||||||
<div>userId: {userId || "-"}</div>
|
// <div>userId: {userId || "-"}</div>
|
||||||
</div>
|
// </div>
|
||||||
<div className="text-sm text-muted-foreground">
|
// <div className="text-sm text-muted-foreground">
|
||||||
如果未登录,会被中间件重定向到统一登录页。
|
// 如果未登录,会被中间件重定向到统一登录页。
|
||||||
</div>
|
// </div>
|
||||||
</div>
|
// </div>
|
||||||
</main>
|
// </main>
|
||||||
)
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/dashboard")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import Link from "next/link";
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FileText,
|
FileText,
|
||||||
Tags,
|
Tags,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Settings,
|
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
LogOut,
|
LogOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { ModeToggle } from "@/components/mode-toggle";
|
import { ModeToggle } from "@/components/mode-toggle"
|
||||||
|
|
||||||
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {}
|
type SidebarProps = React.HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
export function Sidebar({ className }: SidebarProps) {
|
export function Sidebar({ className }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname()
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -34,6 +33,12 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
href: "/columns",
|
href: "/columns",
|
||||||
active: pathname.startsWith("/columns"),
|
active: pathname.startsWith("/columns"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "文章管理",
|
||||||
|
icon: FileText,
|
||||||
|
href: "/articles",
|
||||||
|
active: pathname.startsWith("/articles"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "标签管理",
|
label: "标签管理",
|
||||||
icon: Tags,
|
icon: Tags,
|
||||||
@@ -46,13 +51,7 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
href: "/media",
|
href: "/media",
|
||||||
active: pathname.startsWith("/media"),
|
active: pathname.startsWith("/media"),
|
||||||
},
|
},
|
||||||
{
|
]
|
||||||
label: "文章管理",
|
|
||||||
icon: FileText,
|
|
||||||
href: "/articles",
|
|
||||||
active: pathname.startsWith("/articles"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("pb-12 min-h-screen border-r bg-card", className)}>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/cms/auth/logout", { method: "POST" })
|
||||||
window.location.href = "/login"; // Redirect handled by middleware mostly, but good to be explicit
|
window.location.href = "/"
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 items-center gap-4 border-b bg-card px-6">
|
<header className="flex h-14 items-center gap-4 border-b bg-card px-6">
|
||||||
<div className="flex-1">
|
<div className="flex-1">{/* Breadcrumbs or Title could go here */}</div>
|
||||||
{/* Breadcrumbs or Title could go here */}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Button variant="ghost" size="icon" onClick={handleLogout} title="退出登录">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleLogout}
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
<LogOut className="h-5 w-5" />
|
<LogOut className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,30 +5,31 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label"
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import * as z from "zod";
|
import { z } from "zod"
|
||||||
import { Column } from "@/services/types";
|
import { Column, CreateColumnRequest } from "@/services/types"
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react"
|
||||||
|
|
||||||
const columnSchema = z.object({
|
const columnSchema = z.object({
|
||||||
name: z.string().min(1, "名称不能为空"),
|
name: z.string().min(1, "名称不能为空"),
|
||||||
slug: z.string().min(1, "Slug不能为空"),
|
slug: z.string().min(1, "Slug不能为空"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional().nullable(),
|
||||||
sort_order: z.number().int().default(0),
|
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 {
|
interface ColumnDialogProps {
|
||||||
open: boolean;
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void
|
||||||
column?: Column | null;
|
column?: Column | null
|
||||||
onSubmit: (values: ColumnFormValues) => void;
|
onSubmit: (values: CreateColumnRequest) => Promise<void> | void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ColumnDialog({
|
export function ColumnDialog({
|
||||||
@@ -49,8 +50,9 @@ export function ColumnDialog({
|
|||||||
slug: "",
|
slug: "",
|
||||||
description: "",
|
description: "",
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
|
parent_id: null,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -59,9 +61,22 @@ export function ColumnDialog({
|
|||||||
slug: column?.slug || "",
|
slug: column?.slug || "",
|
||||||
description: column?.description || "",
|
description: column?.description || "",
|
||||||
sort_order: column?.sort_order || 0,
|
sort_order: column?.sort_order || 0,
|
||||||
});
|
parent_id: column?.parent_id || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [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)
|
||||||
}
|
}
|
||||||
}, [open, column, reset]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -72,7 +87,7 @@ export function ColumnDialog({
|
|||||||
{column ? "修改栏目信息" : "填写新栏目的基本信息"}
|
{column ? "修改栏目信息" : "填写新栏目的基本信息"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="name" className="text-right">
|
<Label htmlFor="name" className="text-right">
|
||||||
@@ -131,5 +146,5 @@ export function ColumnDialog({
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCaption,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
|||||||
@@ -5,29 +5,29 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label"
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import * as z from "zod";
|
import { z } from "zod"
|
||||||
import { Tag } from "@/services/types";
|
import { Tag, CreateTagRequest } from "@/services/types"
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react"
|
||||||
|
|
||||||
const tagSchema = z.object({
|
const tagSchema = z.object({
|
||||||
name: z.string().min(1, "名称不能为空"),
|
name: z.string().min(1, "名称不能为空"),
|
||||||
slug: z.string().min(1, "Slug不能为空"),
|
slug: z.string().min(1, "Slug不能为空"),
|
||||||
kind: z.string().min(1, "类型不能为空").default("default"),
|
kind: z.string().min(1, "类型不能为空").default("default"),
|
||||||
});
|
})
|
||||||
|
|
||||||
type TagFormValues = z.infer<typeof tagSchema>;
|
type TagFormValues = z.input<typeof tagSchema>
|
||||||
|
|
||||||
interface TagDialogProps {
|
interface TagDialogProps {
|
||||||
open: boolean;
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void
|
||||||
tag?: Tag | null;
|
tag?: Tag | null
|
||||||
onSubmit: (values: TagFormValues) => void;
|
onSubmit: (values: CreateTagRequest) => Promise<void> | void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagDialog({
|
export function TagDialog({
|
||||||
@@ -48,7 +48,7 @@ export function TagDialog({
|
|||||||
slug: "",
|
slug: "",
|
||||||
kind: "default",
|
kind: "default",
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -56,9 +56,17 @@ export function TagDialog({
|
|||||||
name: tag?.name || "",
|
name: tag?.name || "",
|
||||||
slug: tag?.slug || "",
|
slug: tag?.slug || "",
|
||||||
kind: tag?.kind || "default",
|
kind: tag?.kind || "default",
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
}, [open, tag, reset])
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TagFormValues) => {
|
||||||
|
await onSubmit({
|
||||||
|
name: data.name,
|
||||||
|
slug: data.slug,
|
||||||
|
kind: data.kind ?? "default",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [open, tag, reset]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -69,7 +77,7 @@ export function TagDialog({
|
|||||||
{tag ? "修改标签信息" : "填写新标签的基本信息"}
|
{tag ? "修改标签信息" : "填写新标签的基本信息"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Label htmlFor="name" className="text-right">
|
<Label htmlFor="name" className="text-right">
|
||||||
@@ -119,5 +127,5 @@ export function TagDialog({
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { X } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ const ToastViewport = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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]",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -35,7 +34,7 @@ const toastVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
const Toast = React.forwardRef<
|
||||||
@@ -61,7 +60,7 @@ const ToastAction = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -76,7 +75,7 @@ const ToastClose = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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=""
|
toast-close=""
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,27 +1,329 @@
|
|||||||
import axios from "axios";
|
// --- 类型定义 ---
|
||||||
|
|
||||||
const api = axios.create({
|
interface FetchOptions extends RequestInit {
|
||||||
baseURL: "/api/cms",
|
params?: Record<string, string | number | boolean | undefined | null>
|
||||||
timeout: 10000,
|
responseType?: "json" | "text" | "blob" | "arrayBuffer"
|
||||||
|
onUploadProgress?: (progress: number) => void // 0-100
|
||||||
|
timeout?: number // 毫秒
|
||||||
|
}
|
||||||
|
|
||||||
|
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 searchParams.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带超时的 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: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
...defaultHeaders,
|
||||||
|
...headers,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
|
||||||
api.interceptors.response.use(
|
// 统一错误处理
|
||||||
(response) => response,
|
if (!response.ok) {
|
||||||
async (error) => {
|
// 处理 401 认证失效
|
||||||
if (error.response?.status === 401) {
|
if (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") {
|
if (typeof window !== "undefined") {
|
||||||
// window.location.href = "/login"; // Or let middleware handle it on next navigation
|
// 这里通常不需要做跳转,因为 Next.js Middleware 已经处理了刷新
|
||||||
|
// 如果到了这里,说明刷新失败或 Token 无效,可以根据业务需求决定是否跳转
|
||||||
|
// window.location.href = "/auth-error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default api;
|
// 尝试解析错误信息
|
||||||
|
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
1138
src/services/types.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,71 +1,24 @@
|
|||||||
export interface Column {
|
import { components } from "./types.d"
|
||||||
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 {
|
export type Column = components["schemas"]["Column"]
|
||||||
tenant_id: string;
|
export type CreateColumnRequest = components["schemas"]["CreateColumnRequest"]
|
||||||
id: string;
|
export type UpdateColumnRequest = components["schemas"]["UpdateColumnRequest"]
|
||||||
kind: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Media {
|
export type Tag = components["schemas"]["Tag"]
|
||||||
tenant_id: string;
|
export type CreateTagRequest = components["schemas"]["CreateTagRequest"]
|
||||||
id: string;
|
export type UpdateTagRequest = components["schemas"]["UpdateTagRequest"]
|
||||||
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 {
|
export type Media = components["schemas"]["Media"]
|
||||||
tenant_id: string;
|
export type CreateMediaRequest = components["schemas"]["CreateMediaRequest"]
|
||||||
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 {
|
export type Article = components["schemas"]["Article"]
|
||||||
tenant_id: string;
|
export type CreateArticleRequest = components["schemas"]["CreateArticleRequest"]
|
||||||
id: string;
|
export type UpdateArticleRequest = components["schemas"]["UpdateArticleRequest"]
|
||||||
article_id: string;
|
export type ArticleVersion = components["schemas"]["ArticleVersion"]
|
||||||
version: number;
|
export type ArticleWithTags = components["schemas"]["ArticleWithTags"]
|
||||||
title: string;
|
|
||||||
summary: string | null;
|
|
||||||
content: string;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
created_by: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export type PagedArticle = components["schemas"]["Paged_Article"]
|
||||||
data: T[];
|
export type PagedColumn = components["schemas"]["Paged_Column"]
|
||||||
total: number;
|
export type PagedTag = components["schemas"]["Paged_Tag"]
|
||||||
page: number;
|
export type PagedMedia = components["schemas"]["Paged_Media"]
|
||||||
page_size: number;
|
export type PagedArticleVersion = components["schemas"]["Paged_ArticleVersion"]
|
||||||
total_pages: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["dom", "dom.iterable", "es2022"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es2022"
|
||||||
|
],
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -20,15 +24,20 @@
|
|||||||
],
|
],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"**/*.ts",
|
"src/**/*.ts",
|
||||||
"**/*.tsx",
|
"src/**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
".next"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user