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", "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",

View File

@@ -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>
); )
} }

View File

@@ -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}
/> />

View File

@@ -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: "图片和文件资源",
}, },

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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;
}
} }

View File

@@ -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")
} }

View File

@@ -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>
); )
} }

View File

@@ -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>
); )
} }

View File

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

View File

@@ -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>
); )
} }

View File

@@ -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}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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

File diff suppressed because one or more lines are too long