Compare commits
2 Commits
b3c0af91b8
...
14f50467f0
Author | SHA1 | Date | |
---|---|---|---|
14f50467f0 | |||
66d47f6cfa |
209
frontend/app/dashboard/categories.tsx
Normal file
209
frontend/app/dashboard/categories.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import { Template } from "interface/template";
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
Table,
|
||||
Button,
|
||||
TextField,
|
||||
ScrollArea,
|
||||
Dialog,
|
||||
IconButton
|
||||
} from "@radix-ui/themes";
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
Pencil1Icon,
|
||||
TrashIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import type { Category } from "interface/fields";
|
||||
|
||||
// 模拟分类数据
|
||||
const mockCategories: (Category & { id: number; count: number })[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "前端开发",
|
||||
parentId: undefined,
|
||||
count: 15
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "React",
|
||||
parentId: "1",
|
||||
count: 8
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Vue",
|
||||
parentId: "1",
|
||||
count: 5
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "后端开发",
|
||||
parentId: undefined,
|
||||
count: 12
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Node.js",
|
||||
parentId: "4",
|
||||
count: 6
|
||||
}
|
||||
];
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState("");
|
||||
const [selectedParentId, setSelectedParentId] = useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 页面标题和操作栏 */}
|
||||
<Flex justify="between" align="center" className="mb-6">
|
||||
<Box>
|
||||
<Heading size="6" className="text-[--gray-12] mb-2">
|
||||
分类管理
|
||||
</Heading>
|
||||
<Text className="text-[--gray-11]">
|
||||
共 {mockCategories.length} 个分类
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
className="bg-[--accent-9]"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
新建分类
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<Box className="w-full sm:w-64 mb-6">
|
||||
<TextField.Root
|
||||
placeholder="搜索分类..."
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
|
||||
>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
|
||||
{/* 分类列表 */}
|
||||
<Box className="border border-[--gray-6] rounded-lg overflow-hidden">
|
||||
<ScrollArea>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>分类名称</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>文章数量</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>父分类</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>操作</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{mockCategories.map((category) => (
|
||||
<Table.Row key={category.id}>
|
||||
<Table.Cell>
|
||||
<Flex align="center" gap="2">
|
||||
{category.parentId && (
|
||||
<ChevronRightIcon className="w-4 h-4 text-[--gray-11]" />
|
||||
)}
|
||||
<Text>{category.name}</Text>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text>{category.count} 篇</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text>
|
||||
{category.parentId
|
||||
? mockCategories.find(c => c.id.toString() === category.parentId)?.name
|
||||
: '-'
|
||||
}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex gap="2">
|
||||
<Button variant="ghost" size="1">
|
||||
<Pencil1Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="1" color="red">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
{/* 新建分类对话框 */}
|
||||
<Dialog.Root open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<Dialog.Content style={{ maxWidth: 450 }}>
|
||||
<Dialog.Title>新建分类</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
创建一个新的文章分类
|
||||
</Dialog.Description>
|
||||
|
||||
<Flex direction="column" gap="3">
|
||||
<Box>
|
||||
<Text as="label" size="2" mb="1" weight="bold">
|
||||
分类名称
|
||||
</Text>
|
||||
<TextField.Root
|
||||
placeholder="输入分类名称"
|
||||
value={newCategoryName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewCategoryName(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" mb="1" weight="bold">
|
||||
父分类
|
||||
</Text>
|
||||
<select
|
||||
className="w-full h-9 px-3 rounded-md bg-[--gray-1] border border-[--gray-6] text-[--gray-12]"
|
||||
value={selectedParentId}
|
||||
onChange={(e) => setSelectedParentId(e.target.value)}
|
||||
>
|
||||
<option value="">无</option>
|
||||
{mockCategories
|
||||
.filter(c => !c.parentId)
|
||||
.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
取消
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Close>
|
||||
<Button className="bg-[--accent-9]">
|
||||
创建
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Box>
|
||||
);
|
||||
});
|
297
frontend/app/dashboard/comments.tsx
Normal file
297
frontend/app/dashboard/comments.tsx
Normal file
@ -0,0 +1,297 @@
|
||||
import { Template } from "interface/template";
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
Table,
|
||||
Button,
|
||||
TextField,
|
||||
DropdownMenu,
|
||||
ScrollArea,
|
||||
DataList,
|
||||
Avatar,
|
||||
Badge
|
||||
} from "@radix-ui/themes";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CheckIcon,
|
||||
Cross2Icon,
|
||||
ChatBubbleIcon,
|
||||
TrashIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
|
||||
// 模拟评论数据
|
||||
const mockComments = [
|
||||
{
|
||||
id: 1,
|
||||
content: "这篇文章写得很好,对我帮助很大!",
|
||||
author: "张三",
|
||||
postTitle: "构建现代化的前端开发工作流",
|
||||
createdAt: new Date("2024-03-15"),
|
||||
status: "pending", // pending, approved, rejected
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: "文章内容很专业,讲解得很清楚。",
|
||||
author: "李四",
|
||||
postTitle: "React 18 新特性详解",
|
||||
createdAt: new Date("2024-03-14"),
|
||||
status: "approved",
|
||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2"
|
||||
},
|
||||
// 可以添加更多模拟数据
|
||||
];
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>("all");
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
switch(status) {
|
||||
case 'approved':
|
||||
return 'bg-[--green-3] text-[--green-11]';
|
||||
case 'rejected':
|
||||
return 'bg-[--red-3] text-[--red-11]';
|
||||
default:
|
||||
return 'bg-[--yellow-3] text-[--yellow-11]';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch(status) {
|
||||
case 'approved':
|
||||
return '已通过';
|
||||
case 'rejected':
|
||||
return '已拒绝';
|
||||
default:
|
||||
return '待审核';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 页面标题和统计 */}
|
||||
<Flex justify="between" align="center" className="mb-6">
|
||||
<Box>
|
||||
<Heading size="6" className="text-[--gray-12] mb-2">
|
||||
评论管理
|
||||
</Heading>
|
||||
<Text className="text-[--gray-11]">
|
||||
共 {mockComments.length} 条评论
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索和筛选栏 */}
|
||||
<Flex
|
||||
gap="4"
|
||||
className="mb-6 flex-col sm:flex-row"
|
||||
>
|
||||
<Box className="w-full sm:w-64">
|
||||
<TextField.Root
|
||||
placeholder="搜索评论..."
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
|
||||
>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="surface">
|
||||
状态: {selectedStatus === 'all' ? '全部' : selectedStatus}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onClick={() => setSelectedStatus('all')}>
|
||||
全部
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => setSelectedStatus('pending')}>
|
||||
待审核
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => setSelectedStatus('approved')}>
|
||||
已通过
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => setSelectedStatus('rejected')}>
|
||||
已拒绝
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Flex>
|
||||
|
||||
{/* 评论列表 */}
|
||||
<Box className="border border-[--gray-6] rounded-lg overflow-hidden">
|
||||
<ScrollArea>
|
||||
{/* 桌面端表格视图 */}
|
||||
<div className="hidden sm:block">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>评论者</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>评论内容</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>文章</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>状态</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>时间</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>操作</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{mockComments.map((comment) => (
|
||||
<Table.Row key={comment.id}>
|
||||
<Table.Cell>
|
||||
<Flex align="center" gap="2">
|
||||
<Avatar
|
||||
src={comment.avatar}
|
||||
fallback="U"
|
||||
size="2"
|
||||
radius="full"
|
||||
/>
|
||||
<Text>{comment.author}</Text>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text className="line-clamp-2">
|
||||
{comment.content}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text className="line-clamp-1">
|
||||
{comment.postTitle}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex gap="2">
|
||||
{comment.status === 'approved' && <Badge color="green">已通过</Badge>}
|
||||
{comment.status === 'pending' && <Badge color="orange">待审核</Badge>}
|
||||
{comment.status === 'rejected' && <Badge color="red">已拒绝</Badge>}
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{comment.createdAt.toLocaleDateString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex gap="2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="1"
|
||||
className="text-[--green-11] hover:text-[--green-12]"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="1"
|
||||
className="text-[--red-11] hover:text-[--red-12]"
|
||||
>
|
||||
<Cross2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="1"
|
||||
color="red"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
{/* 移动端列表视图 */}
|
||||
<div className="block sm:hidden">
|
||||
{mockComments.map((comment) => (
|
||||
<DataList.Root key={comment.id} className="p-4 border-b border-[--gray-6] last:border-b-0">
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">评论者</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Flex align="center" gap="2">
|
||||
<Avatar
|
||||
src={comment.avatar}
|
||||
fallback="U"
|
||||
size="2"
|
||||
radius="full"
|
||||
/>
|
||||
<Text>{comment.author}</Text>
|
||||
</Flex>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">评论内容</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Text className="line-clamp-3">{comment.content}</Text>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">文章</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Text className="line-clamp-1">{comment.postTitle}</Text>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">状态</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Flex gap="2">
|
||||
{comment.status === 'approved' && <Badge color="green">已通过</Badge>}
|
||||
{comment.status === 'pending' && <Badge color="orange">待审核</Badge>}
|
||||
{comment.status === 'rejected' && <Badge color="red">已拒绝</Badge>}
|
||||
</Flex>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">时间</DataList.Label>
|
||||
<DataList.Value>
|
||||
{comment.createdAt.toLocaleDateString()}
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">操作</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Flex gap="2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="1"
|
||||
className="text-[--green-11] hover:text-[--green-12]"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="1"
|
||||
className="text-[--red-11] hover:text-[--red-12]"
|
||||
>
|
||||
<Cross2Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="1"
|
||||
color="red"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Flex>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
</DataList.Root>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
305
frontend/app/dashboard/files.tsx
Normal file
305
frontend/app/dashboard/files.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
import { Template } from "interface/template";
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
Table,
|
||||
Button,
|
||||
TextField,
|
||||
DropdownMenu,
|
||||
ScrollArea,
|
||||
Dialog,
|
||||
DataList
|
||||
} from "@radix-ui/themes";
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
FileIcon,
|
||||
TrashIcon,
|
||||
DownloadIcon,
|
||||
DotsHorizontalIcon,
|
||||
FileTextIcon,
|
||||
ImageIcon,
|
||||
VideoIcon
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import type { Resource } from "interface/fields";
|
||||
|
||||
// 模拟文件数据
|
||||
const mockFiles: Resource[] = [
|
||||
{
|
||||
id: 1,
|
||||
authorId: "1",
|
||||
name: "前端开发规范.pdf",
|
||||
sizeBytes: 1024 * 1024 * 2, // 2MB
|
||||
storagePath: "/files/frontend-guide.pdf",
|
||||
fileType: "application/pdf",
|
||||
category: "documents",
|
||||
description: "前端开发规范文档",
|
||||
createdAt: new Date("2024-03-15")
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
authorId: "1",
|
||||
name: "项目架构图.png",
|
||||
sizeBytes: 1024 * 512, // 512KB
|
||||
storagePath: "/files/architecture.png",
|
||||
fileType: "image/png",
|
||||
category: "images",
|
||||
description: "项目整体架构示意图",
|
||||
createdAt: new Date("2024-03-14")
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
authorId: "1",
|
||||
name: "API文档.md",
|
||||
sizeBytes: 1024 * 256, // 256KB
|
||||
storagePath: "/files/api-doc.md",
|
||||
fileType: "text/markdown",
|
||||
category: "documents",
|
||||
description: "API接口文档",
|
||||
createdAt: new Date("2024-03-13")
|
||||
}
|
||||
];
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedType, setSelectedType] = useState<string>("all");
|
||||
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 获取文件图标
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith('image/')) return <ImageIcon className="w-4 h-4" />;
|
||||
if (fileType.startsWith('video/')) return <VideoIcon className="w-4 h-4" />;
|
||||
if (fileType.startsWith('text/')) return <FileTextIcon className="w-4 h-4" />;
|
||||
return <FileIcon className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 页面标题和操作栏 */}
|
||||
<Flex justify="between" align="center" className="mb-6">
|
||||
<Box>
|
||||
<Heading size="6" className="text-[--gray-12] mb-2">
|
||||
文件管理
|
||||
</Heading>
|
||||
<Text className="text-[--gray-11]">
|
||||
共 {mockFiles.length} 个文件
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
className="bg-[--accent-9]"
|
||||
onClick={() => setIsUploadDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
上传文件
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索和筛选栏 */}
|
||||
<Flex
|
||||
gap="4"
|
||||
className="mb-6 flex-col sm:flex-row"
|
||||
>
|
||||
<Box className="w-full sm:w-64">
|
||||
<TextField.Root
|
||||
placeholder="搜索文件..."
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
|
||||
>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="surface">
|
||||
类型: {selectedType === 'all' ? '全部' : selectedType}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onClick={() => setSelectedType('all')}>
|
||||
全部
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => setSelectedType('documents')}>
|
||||
文档
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => setSelectedType('images')}>
|
||||
图片
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => setSelectedType('others')}>
|
||||
其他
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Flex>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<Box className="border border-[--gray-6] rounded-lg overflow-hidden">
|
||||
<ScrollArea>
|
||||
{/* 桌面端表格视图 */}
|
||||
<div className="hidden sm:block">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>文件名</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>大小</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>类型</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>上传时间</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>操作</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{mockFiles.map((file) => (
|
||||
<Table.Row key={file.id}>
|
||||
<Table.Cell>
|
||||
<Flex align="center" gap="2">
|
||||
{getFileIcon(file.fileType)}
|
||||
<Text className="font-medium">{file.name}</Text>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{formatFileSize(file.sizeBytes)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text className="text-[--gray-11]">
|
||||
{file.fileType.split('/')[1].toUpperCase()}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{file.createdAt.toLocaleDateString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex gap="2">
|
||||
<Button variant="ghost" size="1">
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="1" color="red">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
{/* 移动端列表视图 */}
|
||||
<div className="block sm:hidden">
|
||||
{mockFiles.map((file) => (
|
||||
<DataList.Root key={file.id} className="p-4 border-b border-[--gray-6] last:border-b-0">
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">文件名</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Flex align="center" gap="2">
|
||||
{getFileIcon(file.fileType)}
|
||||
<Text className="font-medium">{file.name}</Text>
|
||||
</Flex>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">大小</DataList.Label>
|
||||
<DataList.Value>
|
||||
{formatFileSize(file.sizeBytes)}
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">类型</DataList.Label>
|
||||
<DataList.Value>
|
||||
{file.fileType.split('/')[1].toUpperCase()}
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">上传时间</DataList.Label>
|
||||
<DataList.Value>
|
||||
{file.createdAt.toLocaleDateString()}
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">操作</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Flex gap="2">
|
||||
<Button variant="ghost" size="1">
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="1" color="red">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Flex>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
</DataList.Root>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
{/* 上传对话框 */}
|
||||
<Dialog.Root open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
|
||||
<Dialog.Content style={{ maxWidth: 450 }}>
|
||||
<Dialog.Title>上传文件</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
选择要上传的文件
|
||||
</Dialog.Description>
|
||||
|
||||
<Box className="border-2 border-dashed border-[--gray-6] rounded-lg p-8 text-center">
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
// 处理文件上传
|
||||
console.log(e.target.files);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" />
|
||||
<Text className="text-[--gray-11] mb-2">
|
||||
拖拽文件到此处或点击上传
|
||||
</Text>
|
||||
<Text size="1" className="text-[--gray-10]">
|
||||
支持所有常见文件格式
|
||||
</Text>
|
||||
</label>
|
||||
</Box>
|
||||
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
取消
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Close>
|
||||
<Button className="bg-[--accent-9]">
|
||||
开始上传
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Box>
|
||||
);
|
||||
});
|
169
frontend/app/dashboard/index.tsx
Normal file
169
frontend/app/dashboard/index.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import { Template } from "interface/template";
|
||||
import { Container, Heading, Text, Box, Flex, Card } from "@radix-ui/themes";
|
||||
import {
|
||||
BarChartIcon,
|
||||
ReaderIcon,
|
||||
ChatBubbleIcon,
|
||||
PersonIcon,
|
||||
EyeOpenIcon,
|
||||
HeartIcon,
|
||||
RocketIcon,
|
||||
LayersIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// 模拟统计数据
|
||||
const stats = [
|
||||
{
|
||||
label: "文章总数",
|
||||
value: "128",
|
||||
icon: <ReaderIcon className="w-5 h-5" />,
|
||||
trend: "+12%",
|
||||
color: "var(--accent-9)",
|
||||
},
|
||||
{
|
||||
label: "总访问量",
|
||||
value: "25,438",
|
||||
icon: <EyeOpenIcon className="w-5 h-5" />,
|
||||
trend: "+8.2%",
|
||||
color: "var(--green-9)",
|
||||
},
|
||||
{
|
||||
label: "评论数",
|
||||
value: "1,024",
|
||||
icon: <ChatBubbleIcon className="w-5 h-5" />,
|
||||
trend: "+5.4%",
|
||||
color: "var(--blue-9)",
|
||||
},
|
||||
{
|
||||
label: "用户互动",
|
||||
value: "3,842",
|
||||
icon: <HeartIcon className="w-5 h-5" />,
|
||||
trend: "+15.3%",
|
||||
color: "var(--pink-9)",
|
||||
},
|
||||
];
|
||||
|
||||
// 模拟最近文章数据
|
||||
const recentPosts = [
|
||||
{
|
||||
title: "构建现代化的前端开发工作流",
|
||||
views: 1234,
|
||||
comments: 23,
|
||||
likes: 89,
|
||||
status: "published",
|
||||
},
|
||||
{
|
||||
title: "React 18 新特性详解",
|
||||
views: 892,
|
||||
comments: 15,
|
||||
likes: 67,
|
||||
status: "published",
|
||||
},
|
||||
{
|
||||
title: "TypeScript 高级特性指南",
|
||||
views: 756,
|
||||
comments: 12,
|
||||
likes: 45,
|
||||
status: "draft",
|
||||
},
|
||||
{
|
||||
title: "前端性能优化实践",
|
||||
views: 645,
|
||||
comments: 8,
|
||||
likes: 34,
|
||||
status: "published",
|
||||
},
|
||||
];
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
return (
|
||||
<Box>
|
||||
{/* 页面标题 */}
|
||||
<Heading size="6" className="mb-6 text-[--gray-12]">
|
||||
仪表盘
|
||||
</Heading>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Box className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4 hover-card border border-[--gray-6]">
|
||||
<Flex justify="between" align="center">
|
||||
<Box>
|
||||
<Text className="text-[--gray-11] mb-1" size="2">
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Heading size="6" className="text-[--gray-12]">
|
||||
{stat.value}
|
||||
</Heading>
|
||||
<Text className="text-[--green-9]" size="2">
|
||||
{stat.trend}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: `color-mix(in srgb, ${stat.color} 15%, transparent)` }}
|
||||
>
|
||||
<Box style={{ color: stat.color }}>
|
||||
{stat.icon}
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 最近文章列表 */}
|
||||
<Card className="w-full p-4 border border-[--gray-6] hover-card">
|
||||
<Heading size="3" className="mb-4 text-[--gray-12]">
|
||||
最近文章
|
||||
</Heading>
|
||||
<Box className="space-y-4">
|
||||
{recentPosts.map((post, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
className="p-3 rounded-lg border border-[--gray-6] hover:border-[--accent-9] transition-colors cursor-pointer"
|
||||
>
|
||||
<Flex justify="between" align="start" gap="3">
|
||||
<Box className="flex-1 min-w-0">
|
||||
<Text className="text-[--gray-12] font-medium mb-2 truncate">
|
||||
{post.title}
|
||||
</Text>
|
||||
<Flex gap="3">
|
||||
<Flex align="center" gap="1">
|
||||
<EyeOpenIcon className="w-3 h-3 text-[--gray-11]" />
|
||||
<Text size="1" className="text-[--gray-11]">
|
||||
{post.views}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap="1">
|
||||
<ChatBubbleIcon className="w-3 h-3 text-[--gray-11]" />
|
||||
<Text size="1" className="text-[--gray-11]">
|
||||
{post.comments}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap="1">
|
||||
<HeartIcon className="w-3 h-3 text-[--gray-11]" />
|
||||
<Text size="1" className="text-[--gray-11]">
|
||||
{post.likes}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box
|
||||
className={`px-2 py-1 rounded-full text-xs
|
||||
${post.status === 'published'
|
||||
? 'bg-[--green-3] text-[--green-11]'
|
||||
: 'bg-[--gray-3] text-[--gray-11]'
|
||||
}`}
|
||||
>
|
||||
{post.status === 'published' ? '已发布' : '草稿'}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
});
|
266
frontend/app/dashboard/layout.tsx
Normal file
266
frontend/app/dashboard/layout.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
import { Layout } from "interface/layout";
|
||||
import { ThemeModeToggle } from "hooks/ThemeMode";
|
||||
import { Container, Flex, Box, Link, Button } from "@radix-ui/themes";
|
||||
import {
|
||||
HamburgerMenuIcon,
|
||||
Cross1Icon,
|
||||
PersonIcon,
|
||||
ExitIcon,
|
||||
DashboardIcon,
|
||||
GearIcon,
|
||||
FileTextIcon,
|
||||
ReaderIcon,
|
||||
LayersIcon,
|
||||
FileIcon,
|
||||
ColorWheelIcon,
|
||||
HomeIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { Theme } from "@radix-ui/themes";
|
||||
import { useState, useEffect } from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import throttle from "lodash/throttle";
|
||||
|
||||
// 定义侧边栏菜单项
|
||||
const menuItems = [
|
||||
{
|
||||
icon: <DashboardIcon className="w-4 h-4" />,
|
||||
label: "仪表盘",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
icon: <FileTextIcon className="w-4 h-4" />,
|
||||
label: "文章管理",
|
||||
path: "/dashboard/posts",
|
||||
},
|
||||
{
|
||||
icon: <ReaderIcon className="w-4 h-4" />,
|
||||
label: "评论管理",
|
||||
path: "/dashboard/comments",
|
||||
},
|
||||
{
|
||||
icon: <LayersIcon className="w-4 h-4" />,
|
||||
label: "分类管理",
|
||||
path: "/dashboard/categories",
|
||||
},
|
||||
{
|
||||
icon: <FileIcon className="w-4 h-4" />,
|
||||
label: "文件管理",
|
||||
path: "/dashboard/files",
|
||||
},
|
||||
{
|
||||
icon: <ColorWheelIcon className="w-4 h-4" />,
|
||||
label: "主题管理",
|
||||
path: "/dashboard/themes",
|
||||
},
|
||||
{
|
||||
icon: <GearIcon className="w-4 h-4" />,
|
||||
label: "系统设置",
|
||||
path: "/dashboard/settings",
|
||||
},
|
||||
{
|
||||
icon: <PersonIcon className="w-4 h-4" />,
|
||||
label: "用户管理",
|
||||
path: "/dashboard/users",
|
||||
},
|
||||
{
|
||||
icon: <LayersIcon className="w-4 h-4" />,
|
||||
label: "插件管理",
|
||||
path: "/dashboard/plugins",
|
||||
},
|
||||
];
|
||||
|
||||
export default new Layout(({ children }) => {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = throttle(() => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
handleResize.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box className="min-h-screen">
|
||||
<Theme
|
||||
grayColor="gray"
|
||||
accentColor="indigo"
|
||||
radius="large"
|
||||
panelBackground="solid"
|
||||
>
|
||||
<Box className="flex h-screen">
|
||||
{/* 侧边栏 */}
|
||||
<Box
|
||||
className={`
|
||||
fixed lg:static h-full
|
||||
transform lg:transform-none transition-transform duration-300
|
||||
${mobileMenuOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
${sidebarCollapsed ? "lg:w-20" : "lg:w-64"}
|
||||
bg-[--gray-1] border-r border-[--gray-6]
|
||||
flex flex-col z-30
|
||||
`}
|
||||
>
|
||||
{/* Logo区域 */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="between"
|
||||
className="h-16 px-4 border-b border-[--gray-6]"
|
||||
>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={`flex items-center gap-2 transition-all ${
|
||||
sidebarCollapsed ? "lg:justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
<Box className="w-8 h-8 rounded-lg bg-[--accent-9] flex items-center justify-center">
|
||||
<span className="text-white font-bold">A</span>
|
||||
</Box>
|
||||
<span
|
||||
className={`text-[--gray-12] font-medium ${
|
||||
sidebarCollapsed ? "lg:hidden" : ""
|
||||
}`}
|
||||
>
|
||||
后台管理
|
||||
</span>
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
{/* 菜单列表区域添加滚动 */}
|
||||
<Box className="flex-1 overflow-y-auto">
|
||||
<Box className="py-4">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-2.5 mx-2 rounded-md
|
||||
text-[--gray-11] hover:text-[--gray-12]
|
||||
hover:bg-[--gray-3] transition-colors
|
||||
${sidebarCollapsed ? "lg:justify-center" : ""}
|
||||
`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className={sidebarCollapsed ? "lg:hidden" : ""}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Box className="flex-1 flex flex-col lg:ml-0 w-full relative">
|
||||
{/* 顶部导航栏 */}
|
||||
<Box
|
||||
className={`
|
||||
h-16 border-b border-[--gray-6] bg-[--gray-1]
|
||||
sticky top-0 z-20 w-full
|
||||
`}
|
||||
>
|
||||
<Flex
|
||||
justify="between"
|
||||
align="center"
|
||||
className="h-full px-4 lg:px-6"
|
||||
>
|
||||
{/* 左侧菜单按钮 */}
|
||||
<Flex gap="4" align="center">
|
||||
{mobileMenuOpen ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="3"
|
||||
className="lg:hidden text-base"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<Cross1Icon className="w-5 h-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="3"
|
||||
className="lg:hidden text-base"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<HamburgerMenuIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="3"
|
||||
className="hidden lg:flex items-center text-base"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
>
|
||||
<HamburgerMenuIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* 右侧用户菜单 */}
|
||||
<Flex align="center" gap="4">
|
||||
<Box className="flex items-center border-r border-[--gray-6] pr-4 [&_button]:w-10 [&_button]:h-10 [&_svg]:w-6 [&_svg]:h-6">
|
||||
<ThemeModeToggle />
|
||||
</Box>
|
||||
|
||||
{/* 返回主页按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="3"
|
||||
className="gap-2 text-base"
|
||||
onClick={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
>
|
||||
<HomeIcon className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">返回主页</span>
|
||||
</Button>
|
||||
|
||||
{/* 退出登录按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="3"
|
||||
className="gap-2 text-base"
|
||||
onClick={() => {
|
||||
// 这里添加退出登录的逻辑
|
||||
console.log('退出登录');
|
||||
}}
|
||||
>
|
||||
<ExitIcon className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">退出登录</span>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 页面内容区域 */}
|
||||
<Box
|
||||
id="main-content"
|
||||
className="flex-1 overflow-y-auto bg-[--gray-2]"
|
||||
>
|
||||
<Container
|
||||
size="4"
|
||||
className="py-6 px-4"
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 移动端菜单遮罩 */}
|
||||
{mobileMenuOpen && (
|
||||
<Box
|
||||
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Theme>
|
||||
</Box>
|
||||
);
|
||||
});
|
186
frontend/app/dashboard/login.tsx
Normal file
186
frontend/app/dashboard/login.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import "./styles/login.css";
|
||||
import { Template } from "interface/template";
|
||||
import { Container, Heading, Text, Box, Flex, Button } from "@radix-ui/themes";
|
||||
import { PersonIcon, LockClosedIcon } from "@radix-ui/react-icons";
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { AnimatedBackground } from 'hooks/Background';
|
||||
import { useThemeMode, ThemeModeToggle } from 'hooks/ThemeMode';
|
||||
import { useNotification } from 'hooks/Notification';
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { mode } = useThemeMode();
|
||||
const [hasBackgroundError, setHasBackgroundError] = useState(false);
|
||||
const notification = useNotification();
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// 登录框动画
|
||||
gsap.from(".login-box", {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 1,
|
||||
ease: "power3.out",
|
||||
});
|
||||
|
||||
// 表单元素动画
|
||||
gsap.from(".form-element", {
|
||||
x: -20,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
stagger: 0.1,
|
||||
ease: "power2.out",
|
||||
delay: 0.3,
|
||||
});
|
||||
|
||||
// 按钮动画
|
||||
gsap.from(".login-button", {
|
||||
scale: 0.9,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
ease: "back.out(1.7)",
|
||||
delay: 0.8,
|
||||
});
|
||||
}, containerRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 这里添加登录逻辑
|
||||
await new Promise(resolve => setTimeout(resolve, 1500)); // 模拟API请求
|
||||
|
||||
// 登录成功的通知
|
||||
notification.success('登录成功', '欢迎回来!');
|
||||
|
||||
// 登录成功后的处理
|
||||
console.log("Login successful");
|
||||
} catch (error) {
|
||||
// 登录失败的通知
|
||||
notification.error('登录失败', '用户名或密码错误');
|
||||
console.error("Login failed:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackgroundError = () => {
|
||||
console.log('Background failed to load, switching to fallback');
|
||||
setHasBackgroundError(true);
|
||||
};
|
||||
|
||||
// 使用 useMemo 包裹背景组件
|
||||
const backgroundComponent = useMemo(() => (
|
||||
!hasBackgroundError && <AnimatedBackground onError={handleBackgroundError} />
|
||||
), [hasBackgroundError]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
{backgroundComponent}
|
||||
|
||||
<Box
|
||||
className="fixed top-4 right-4 z-20 w-10 h-10 flex items-center justify-center [&_button]:w-10 [&_button]:h-10 [&_svg]:w-6 [&_svg]:h-6"
|
||||
style={{
|
||||
'--button-color': 'var(--gray-12)',
|
||||
'--button-hover-color': 'var(--accent-9)'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<ThemeModeToggle />
|
||||
</Box>
|
||||
|
||||
<Container
|
||||
ref={containerRef}
|
||||
className={`relative z-10 h-screen w-full flex items-center justify-center transition-all duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<Box className="w-full max-w-md mx-auto px-4">
|
||||
<Box
|
||||
className="login-box backdrop-blur-sm rounded-lg shadow-lg p-8 border transition-colors duration-300"
|
||||
style={{
|
||||
backgroundColor: mode === 'dark' ? 'var(--gray-2-alpha-80)' : 'var(--white-alpha-80)',
|
||||
borderColor: 'var(--gray-6)'
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Flex direction="column" align="center" className="mb-8">
|
||||
<Heading size="6" className="text-center mb-2">
|
||||
后台
|
||||
</Heading>
|
||||
|
||||
</Flex>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<form onSubmit={handleLogin}>
|
||||
<Flex direction="column" gap="4">
|
||||
{/* 用户名输入框 */}
|
||||
<Box className="form-element input-box relative">
|
||||
<input
|
||||
className="login-input"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label>用户名</label>
|
||||
</Box>
|
||||
|
||||
{/* 密码输入框 */}
|
||||
<Box className="form-element input-box relative">
|
||||
<input
|
||||
className="login-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<label>密码</label>
|
||||
</Box>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<Button
|
||||
className="login-button w-full h-10 transition-colors duration-300 hover:bg-[--hover-bg]"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-9)',
|
||||
color: 'white',
|
||||
'--hover-bg': 'var(--accent-10)'
|
||||
} as React.CSSProperties}
|
||||
size="3"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
|
||||
{/* 其他选项 */}
|
||||
<Flex justify="center" className="form-element">
|
||||
<Text
|
||||
size="2"
|
||||
className="cursor-pointer transition-colors duration-300 hover:text-[--hover-color]"
|
||||
style={{
|
||||
color: 'var(--gray-11)',
|
||||
'--hover-color': 'var(--accent-9)'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
忘记密码?
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</form>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
});
|
283
frontend/app/dashboard/plugins.tsx
Normal file
283
frontend/app/dashboard/plugins.tsx
Normal file
@ -0,0 +1,283 @@
|
||||
import { Template } from "interface/template";
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
Card,
|
||||
Button,
|
||||
TextField,
|
||||
DropdownMenu,
|
||||
ScrollArea,
|
||||
Dialog,
|
||||
Tabs,
|
||||
Switch,
|
||||
IconButton
|
||||
} from "@radix-ui/themes";
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
DownloadIcon,
|
||||
GearIcon,
|
||||
CodeIcon,
|
||||
Cross2Icon,
|
||||
CheckIcon,
|
||||
UpdateIcon,
|
||||
TrashIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import type { PluginConfig } from "interface/plugin";
|
||||
|
||||
// 模拟插件数据
|
||||
const mockPlugins: (PluginConfig & { id: number; preview?: string; installed?: boolean })[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "comment-system",
|
||||
displayName: "评论系统",
|
||||
version: "1.0.0",
|
||||
description: "支持多种评论系统集成,包括Disqus、Gitalk等",
|
||||
author: "Admin",
|
||||
enabled: true,
|
||||
icon: "https://api.iconify.design/material-symbols:comment.svg",
|
||||
preview: "https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format",
|
||||
managePath: "/dashboard/plugins/comment-system",
|
||||
installed: true,
|
||||
configuration: {
|
||||
system: {
|
||||
title: "评论系统配置",
|
||||
description: "配置评论系统参数",
|
||||
data: {
|
||||
provider: "gitalk",
|
||||
clientId: "",
|
||||
clientSecret: ""
|
||||
}
|
||||
}
|
||||
},
|
||||
routes: new Set()
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "image-optimization",
|
||||
displayName: "图片优化",
|
||||
version: "1.0.0",
|
||||
description: "自动优化上传的图片,支持压缩、裁剪、水印等功能",
|
||||
author: "ThirdParty",
|
||||
enabled: false,
|
||||
icon: "https://api.iconify.design/material-symbols:image.svg",
|
||||
preview: "https://images.unsplash.com/photo-1618005198919-d3d4b5a92ead?w=500&auto=format",
|
||||
installed: true,
|
||||
configuration: {
|
||||
system: {
|
||||
title: "图片优化配置",
|
||||
description: "配置图片优化参数",
|
||||
data: {
|
||||
quality: 80,
|
||||
maxWidth: 1920,
|
||||
watermark: false
|
||||
}
|
||||
}
|
||||
},
|
||||
routes: new Set()
|
||||
}
|
||||
];
|
||||
|
||||
// 模拟市场插件数据
|
||||
interface MarketPlugin {
|
||||
id: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
preview?: string;
|
||||
downloads: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
const marketPlugins: MarketPlugin[] = [
|
||||
{
|
||||
id: 4,
|
||||
name: "image-optimization",
|
||||
displayName: "图片优化",
|
||||
version: "1.0.0",
|
||||
description: "自动优化上传的图片,支持压缩、裁剪、水印等功能",
|
||||
author: "ThirdParty",
|
||||
preview: "https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format",
|
||||
downloads: 1200,
|
||||
rating: 4.5
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "markdown-plus",
|
||||
displayName: "Markdown增强",
|
||||
version: "2.0.0",
|
||||
description: "增强的Markdown编辑器,支持更多扩展语法和实时预览",
|
||||
author: "ThirdParty",
|
||||
preview: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=500&auto=format",
|
||||
downloads: 3500,
|
||||
rating: 4.8
|
||||
}
|
||||
];
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<typeof mockPlugins[0] | null>(null);
|
||||
|
||||
// 处理插件启用/禁用
|
||||
const handleTogglePlugin = (pluginId: number) => {
|
||||
// 这里添加启用/禁用插件的逻辑
|
||||
console.log('Toggle plugin:', pluginId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 页面标题和操作栏 */}
|
||||
<Flex justify="between" align="center" className="mb-6">
|
||||
<Box>
|
||||
<Heading size="6" className="text-[--gray-12] mb-2">
|
||||
插件管理
|
||||
</Heading>
|
||||
<Text className="text-[--gray-11]">
|
||||
共 {mockPlugins.length} 个插件
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
className="bg-[--accent-9]"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
安装插件
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<Box className="w-full sm:w-64 mb-6">
|
||||
<TextField.Root
|
||||
placeholder="搜索插件..."
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
|
||||
>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
|
||||
{/* 插件列表 */}
|
||||
<Box className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{mockPlugins.map((plugin) => (
|
||||
<Card key={plugin.id} className="p-4 border border-[--gray-6] hover-card">
|
||||
{/* 插件预览图 */}
|
||||
{plugin.preview && (
|
||||
<Box className="aspect-video mb-4 rounded-lg overflow-hidden bg-[--gray-3]">
|
||||
<img
|
||||
src={plugin.preview}
|
||||
alt={plugin.displayName}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 插件信息 */}
|
||||
<Flex direction="column" gap="2">
|
||||
<Flex justify="between" align="center">
|
||||
<Heading size="3">{plugin.displayName}</Heading>
|
||||
<Switch
|
||||
checked={plugin.enabled}
|
||||
onCheckedChange={() => handleTogglePlugin(plugin.id)}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Text size="1" className="text-[--gray-11]">
|
||||
版本 {plugin.version} · 作者 {plugin.author}
|
||||
</Text>
|
||||
|
||||
<Text size="2" className="text-[--gray-11] line-clamp-2">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Flex gap="2" mt="2">
|
||||
{plugin.managePath && plugin.enabled && (
|
||||
<Button
|
||||
variant="soft"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if(plugin.managePath) {
|
||||
window.location.href = plugin.managePath;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GearIcon className="w-4 h-4" />
|
||||
配置
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="soft"
|
||||
color="red"
|
||||
className="flex-1"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
卸载
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 安装插件对话框 */}
|
||||
<Dialog.Root open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<Dialog.Content style={{ maxWidth: 500 }}>
|
||||
<Dialog.Title>安装插件</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
上传插件包进行安装
|
||||
</Dialog.Description>
|
||||
|
||||
<Box className="mt-4">
|
||||
<Box className="border-2 border-dashed border-[--gray-6] rounded-lg p-8 text-center">
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
id="plugin-upload"
|
||||
accept=".zip"
|
||||
onChange={(e) => {
|
||||
console.log(e.target.files);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="plugin-upload"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<CodeIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" />
|
||||
<Text className="text-[--gray-11] mb-2">
|
||||
点击上传插件包或拖拽到此处
|
||||
</Text>
|
||||
<Text size="1" className="text-[--gray-10]">
|
||||
支持 .zip 格式的插件包
|
||||
</Text>
|
||||
</label>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
取消
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Close>
|
||||
<Button className="bg-[--accent-9]">
|
||||
开始安装
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Box>
|
||||
);
|
||||
});
|
247
frontend/app/dashboard/posts.tsx
Normal file
247
frontend/app/dashboard/posts.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import { Template } from "interface/template";
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
Table,
|
||||
Button,
|
||||
TextField,
|
||||
DropdownMenu,
|
||||
ScrollArea,
|
||||
DataList,
|
||||
Badge
|
||||
} from "@radix-ui/themes";
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
DotsHorizontalIcon,
|
||||
Pencil1Icon,
|
||||
TrashIcon,
|
||||
EyeOpenIcon,
|
||||
ReaderIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import type { PostDisplay } from "interface/fields";
|
||||
|
||||
// 模拟文章数据
|
||||
const mockPosts: PostDisplay[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "构建现代化的前端开发工作流",
|
||||
content: "在现代前端开发中...",
|
||||
authorName: "张三",
|
||||
publishedAt: new Date("2024-03-15"),
|
||||
status: "published",
|
||||
isEditor: false,
|
||||
createdAt: new Date("2024-03-15"),
|
||||
updatedAt: new Date("2024-03-15"),
|
||||
metaKeywords: "",
|
||||
metaDescription: "",
|
||||
categories: [{ name: "前端开发" }],
|
||||
tags: [{ name: "工程化" }, { name: "效率提升" }]
|
||||
},
|
||||
// ... 可以添加更多模拟数据
|
||||
];
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>("all");
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 页面标题和操作栏 */}
|
||||
<Flex justify="between" align="center" className="mb-6">
|
||||
<Heading size="6" className="text-[--gray-12]">
|
||||
文章管理
|
||||
</Heading>
|
||||
<Button className="bg-[--accent-9]">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
新建文章
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索和筛选栏 */}
|
||||
<Flex
|
||||
gap="4"
|
||||
className="mb-6 flex-col sm:flex-row" // 移动端垂直布局,桌面端水平布局
|
||||
>
|
||||
<Box className="w-full sm:w-64">
|
||||
<TextField.Root
|
||||
placeholder="搜索文章..."
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
|
||||
>
|
||||
<TextField.Slot side="right">
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="surface">
|
||||
状态: {selectedStatus === 'all' ? '全部' : selectedStatus}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onClick={() => setSelectedStatus('all')}>
|
||||
全部
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => setSelectedStatus('published')}>
|
||||
已发布
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={() => setSelectedStatus('draft')}>
|
||||
草稿
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Flex>
|
||||
|
||||
{/* 文章列表 */}
|
||||
<Box className="border border-[--gray-6] rounded-lg overflow-hidden">
|
||||
<ScrollArea className="w-full">
|
||||
{/* 桌面端表格视图 */}
|
||||
<div className="hidden sm:block">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>标题</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>作者</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>分类</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>状态</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>发布时间</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>操作</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{mockPosts.map((post) => (
|
||||
<Table.Row key={post.id} className="hover:bg-[--gray-3] block sm:table-row mb-4 sm:mb-0">
|
||||
<Table.Cell className="font-medium block sm:table-cell py-2 sm:py-3 before:content-['标题:'] before:inline-block before:w-20 before:font-normal sm:before:content-none">
|
||||
{post.title}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="block sm:table-cell py-2 sm:py-3 before:content-['作者:'] before:inline-block before:w-20 before:font-normal sm:before:content-none">
|
||||
{post.authorName}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="block sm:table-cell py-2 sm:py-3 before:content-['分类:'] before:inline-block before:w-20 before:font-normal sm:before:content-none">
|
||||
<Flex gap="2" className="inline-flex">
|
||||
{post.categories?.map((category) => (
|
||||
<Text
|
||||
key={category.name}
|
||||
size="1"
|
||||
className="px-2 py-0.5 bg-[--gray-4] rounded"
|
||||
>
|
||||
{category.name}
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="block sm:table-cell py-2 sm:py-3 before:content-['状态:'] before:inline-block before:w-20 before:font-normal sm:before:content-none">
|
||||
<Flex gap="2">
|
||||
{post.status === 'published' ? (
|
||||
<Badge color="green">已发布</Badge>
|
||||
) : (
|
||||
<Badge color="orange">草稿</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="block sm:table-cell py-2 sm:py-3 before:content-['发布时间:'] before:inline-block before:w-20 before:font-normal sm:before:content-none">
|
||||
{post.publishedAt?.toLocaleDateString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="block sm:table-cell py-2 sm:py-3 border-b sm:border-b-0 before:content-['操作:'] before:inline-block before:w-20 before:font-normal sm:before:content-none">
|
||||
<Flex gap="2">
|
||||
<Button variant="ghost" size="1">
|
||||
<Pencil1Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="1">
|
||||
<EyeOpenIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="1" color="red">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
{/* 移动端列表视图 */}
|
||||
<div className="block sm:hidden">
|
||||
{mockPosts.map((post) => (
|
||||
<DataList.Root key={post.id} className="p-4 border-b border-[--gray-6] last:border-b-0">
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">标题</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Text weight="medium">{post.title}</Text>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">作者</DataList.Label>
|
||||
<DataList.Value>{post.authorName}</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">分类</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Flex gap="2">
|
||||
{post.categories?.map((category) => (
|
||||
<Text
|
||||
key={category.name}
|
||||
size="1"
|
||||
className="px-2 py-0.5 bg-[--gray-4] rounded"
|
||||
>
|
||||
{category.name}
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">状态</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Flex gap="2">
|
||||
{post.status === 'published' ? (
|
||||
<Badge color="green">已发布</Badge>
|
||||
) : (
|
||||
<Badge color="orange">草稿</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">发布时间</DataList.Label>
|
||||
<DataList.Value>
|
||||
{post.publishedAt?.toLocaleDateString()}
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
|
||||
<DataList.Item>
|
||||
<DataList.Label minWidth="88px">操作</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Flex gap="2">
|
||||
<Button variant="ghost" size="1">
|
||||
<Pencil1Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="1">
|
||||
<EyeOpenIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="1" color="red">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Flex>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
</DataList.Root>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
230
frontend/app/dashboard/settings.tsx
Normal file
230
frontend/app/dashboard/settings.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
import { Template } from "interface/template";
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
Card,
|
||||
Button,
|
||||
TextField,
|
||||
Switch,
|
||||
Tabs,
|
||||
TextArea
|
||||
} from "@radix-ui/themes";
|
||||
import {
|
||||
GearIcon,
|
||||
PersonIcon,
|
||||
LockClosedIcon,
|
||||
BellIcon,
|
||||
GlobeIcon
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const [siteName, setSiteName] = useState("我的博客");
|
||||
const [siteDescription, setSiteDescription] = useState("一个优雅的博客系统");
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="6" className="text-[--gray-12] mb-6">
|
||||
系统设置
|
||||
</Heading>
|
||||
|
||||
<Tabs.Root defaultValue="general">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="general">
|
||||
<GearIcon className="w-4 h-4 mr-2" />
|
||||
常规设置
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="profile">
|
||||
<PersonIcon className="w-4 h-4 mr-2" />
|
||||
个人资料
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="security">
|
||||
<LockClosedIcon className="w-4 h-4 mr-2" />
|
||||
安全设置
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="notifications">
|
||||
<BellIcon className="w-4 h-4 mr-2" />
|
||||
通知设置
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
{/* 常规设置 */}
|
||||
<Tabs.Content value="general">
|
||||
<Card className="mt-6 p-6 border border-[--gray-6]">
|
||||
<Flex direction="column" gap="4">
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
站点名称
|
||||
</Text>
|
||||
<TextField.Root
|
||||
value={siteName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSiteName(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
站点描述
|
||||
</Text>
|
||||
<TextArea
|
||||
value={siteDescription}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setSiteDescription(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
站点语言
|
||||
</Text>
|
||||
<select
|
||||
className="w-full h-9 px-3 rounded-md bg-[--gray-1] border border-[--gray-6] text-[--gray-12]"
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
时区设置
|
||||
</Text>
|
||||
<select
|
||||
className="w-full h-9 px-3 rounded-md bg-[--gray-1] border border-[--gray-6] text-[--gray-12]"
|
||||
>
|
||||
<option value="UTC+8">UTC+8 北京时间</option>
|
||||
<option value="UTC+0">UTC+0 格林威治时间</option>
|
||||
</select>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* 个人资料 */}
|
||||
<Tabs.Content value="profile">
|
||||
<Card className="mt-6 p-6 border border-[--gray-6]">
|
||||
<Flex direction="column" gap="4">
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
头像
|
||||
</Text>
|
||||
<Flex align="center" gap="4">
|
||||
<Box className="w-20 h-20 rounded-full bg-[--gray-3] flex items-center justify-center">
|
||||
<PersonIcon className="w-8 h-8 text-[--gray-9]" />
|
||||
</Box>
|
||||
<Button variant="soft">更换头像</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
用户名
|
||||
</Text>
|
||||
<TextField.Root defaultValue="admin" />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
邮箱
|
||||
</Text>
|
||||
<TextField.Root defaultValue="admin@example.com" />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
个人简介
|
||||
</Text>
|
||||
<TextArea
|
||||
placeholder="介绍一下自己..."
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* 安全设置 */}
|
||||
<Tabs.Content value="security">
|
||||
<Card className="mt-6 p-6 border border-[--gray-6]">
|
||||
<Flex direction="column" gap="4">
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
修改密码
|
||||
</Text>
|
||||
<Flex direction="column" gap="2">
|
||||
<TextField.Root
|
||||
type="password"
|
||||
placeholder="当前密码"
|
||||
/>
|
||||
<TextField.Root
|
||||
type="password"
|
||||
placeholder="新密码"
|
||||
/>
|
||||
<TextField.Root
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
两步验证
|
||||
</Text>
|
||||
<Flex align="center" gap="4">
|
||||
<Switch defaultChecked />
|
||||
<Text>启用两步验证</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* 通知设置 */}
|
||||
<Tabs.Content value="notifications">
|
||||
<Card className="mt-6 p-6 border border-[--gray-6]">
|
||||
<Flex direction="column" gap="4">
|
||||
<Box>
|
||||
<Flex justify="between" align="center">
|
||||
<Box>
|
||||
<Text weight="bold">邮件通知</Text>
|
||||
<Text size="1" className="text-[--gray-11]">
|
||||
接收新评论和系统通知的邮件提醒
|
||||
</Text>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={emailNotifications}
|
||||
onCheckedChange={setEmailNotifications}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Flex justify="between" align="center">
|
||||
<Box>
|
||||
<Text weight="bold">浏览器通知</Text>
|
||||
<Text size="1" className="text-[--gray-11]">
|
||||
在浏览器中接收实时通知
|
||||
</Text>
|
||||
</Box>
|
||||
<Switch defaultChecked />
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<Flex justify="end" className="mt-6">
|
||||
<Button className="bg-[--accent-9]">
|
||||
保存更改
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
});
|
74
frontend/app/dashboard/styles/login.css
Normal file
74
frontend/app/dashboard/styles/login.css
Normal file
@ -0,0 +1,74 @@
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
font-size: 16px;
|
||||
color: var(--gray-12);
|
||||
margin-bottom: 30px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--gray-8);
|
||||
outline: none;
|
||||
background: transparent;
|
||||
transition: .5s;
|
||||
caret-color: var(--accent-9);
|
||||
}
|
||||
|
||||
/* 输入框的标签动画 */
|
||||
.input-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-box label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 10px 0;
|
||||
font-size: 16px;
|
||||
color: var(--gray-11);
|
||||
pointer-events: none;
|
||||
transition: .5s;
|
||||
}
|
||||
|
||||
.input-box input:focus ~ label,
|
||||
.input-box input:valid ~ label {
|
||||
top: -20px;
|
||||
left: 0;
|
||||
color: var(--accent-9);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 输入框底部边框动画 */
|
||||
.input-box input:focus,
|
||||
.input-box input:valid {
|
||||
border-bottom: 2px solid var(--accent-9);
|
||||
}
|
||||
|
||||
/* 暗色模式适配 */
|
||||
:root[class~="dark"] .login-input {
|
||||
color: var(--gray-11);
|
||||
border-bottom-color: var(--gray-7);
|
||||
caret-color: var(--accent-8);
|
||||
}
|
||||
|
||||
:root[class~="dark"] .input-box label {
|
||||
color: var(--gray-8);
|
||||
}
|
||||
|
||||
:root[class~="dark"] .input-box input:focus ~ label,
|
||||
:root[class~="dark"] .input-box input:valid ~ label {
|
||||
color: var(--accent-9);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background-color: var(--accent-9);
|
||||
color: white;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background-color: var(--accent-10);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
230
frontend/app/dashboard/themes.tsx
Normal file
230
frontend/app/dashboard/themes.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
import { Template } from "interface/template";
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
Card,
|
||||
Button,
|
||||
TextField,
|
||||
ScrollArea,
|
||||
Dialog,
|
||||
Tabs,
|
||||
} from "@radix-ui/themes";
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
CodeIcon,
|
||||
CheckIcon,
|
||||
Cross2Icon,
|
||||
DownloadIcon,
|
||||
GearIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import type { ThemeConfig } from "interface/theme";
|
||||
|
||||
// 模拟主题数据
|
||||
const mockThemes: (ThemeConfig & { id: number; preview: string; active: boolean })[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "echoes",
|
||||
displayName: "Echoes",
|
||||
version: "1.0.0",
|
||||
description: "默认主题",
|
||||
author: "Admin",
|
||||
preview: "https://images.unsplash.com/photo-1481487196290-c152efe083f5?w=500&auto=format",
|
||||
templates: new Map(),
|
||||
configuration: {
|
||||
theme: {
|
||||
title: "主题配置",
|
||||
description: "Echoes主题配置项",
|
||||
data: {
|
||||
colors: {
|
||||
mode: "light",
|
||||
layout: "default"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
routes: new Map(),
|
||||
active: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "minimal",
|
||||
displayName: "Minimal",
|
||||
version: "1.0.0",
|
||||
description: "简约风格主题",
|
||||
author: "Admin",
|
||||
preview: "https://images.unsplash.com/photo-1618005198919-d3d4b5a92ead?w=500&auto=format",
|
||||
templates: new Map(),
|
||||
configuration: {
|
||||
theme: {
|
||||
title: "主题配置",
|
||||
description: "Echoes主题配置项",
|
||||
data: {
|
||||
colors: {
|
||||
mode: "light",
|
||||
layout: "default"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
routes: new Map(),
|
||||
active: false
|
||||
}
|
||||
];
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [selectedTheme, setSelectedTheme] = useState<typeof mockThemes[0] | null>(null);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 页面标题和操作栏 */}
|
||||
<Flex justify="between" align="center" className="mb-6">
|
||||
<Box>
|
||||
<Heading size="6" className="text-[--gray-12] mb-2">
|
||||
主题管理
|
||||
</Heading>
|
||||
<Text className="text-[--gray-11]">
|
||||
共 {mockThemes.length} 个主题
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
className="bg-[--accent-9]"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
安装主题
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<Box className="w-full sm:w-64 mb-6">
|
||||
<TextField.Root placeholder="搜索主题...">
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
|
||||
{/* 主题列表 */}
|
||||
<Box className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{mockThemes.map((theme) => (
|
||||
<Card key={theme.id} className="p-4 border border-[--gray-6] hover-card">
|
||||
{/* 预览图 */}
|
||||
<Box className="aspect-video mb-4 rounded-lg overflow-hidden bg-[--gray-3]">
|
||||
<img
|
||||
src={theme.preview}
|
||||
alt={theme.displayName}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 主题信息 */}
|
||||
<Flex direction="column" gap="2">
|
||||
<Flex justify="between" align="center">
|
||||
<Heading size="3">{theme.displayName}</Heading>
|
||||
{theme.active && (
|
||||
<Text size="1" className="px-2 py-1 bg-[--accent-3] text-[--accent-9] rounded">
|
||||
当前使用
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Text size="1" className="text-[--gray-11]">
|
||||
版本 {theme.version} · 作者 {theme.author}
|
||||
</Text>
|
||||
|
||||
<Text size="2" className="text-[--gray-11] line-clamp-2">
|
||||
{theme.description}
|
||||
</Text>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Flex gap="2" mt="2">
|
||||
{theme.active ? (
|
||||
<Button
|
||||
variant="soft"
|
||||
className="flex-1"
|
||||
onClick={() => window.location.href = `/dashboard/themes/${theme.name}/settings`}
|
||||
>
|
||||
<GearIcon className="w-4 h-4" />
|
||||
配置
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="flex-1 bg-[--accent-9]"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
启用
|
||||
</Button>
|
||||
<Button
|
||||
variant="soft"
|
||||
color="red"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
// 这里添加卸载主题的处理逻辑
|
||||
console.log('卸载主题:', theme.name);
|
||||
}}
|
||||
>
|
||||
<Cross2Icon className="w-4 h-4" />
|
||||
卸载
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 安装主题对话框 */}
|
||||
<Dialog.Root open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<Dialog.Content style={{ maxWidth: 500 }}>
|
||||
<Dialog.Title>安装主题</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
请上传主题包进行安装
|
||||
</Dialog.Description>
|
||||
|
||||
<Box className="mt-4">
|
||||
<Box className="border-2 border-dashed border-[--gray-6] rounded-lg p-8 text-center">
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
id="theme-upload"
|
||||
accept=".zip"
|
||||
onChange={(e) => {
|
||||
console.log(e.target.files);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="theme-upload"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<CodeIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" />
|
||||
<Text className="text-[--gray-11] mb-2">
|
||||
点击上传主题包或拖拽到此处
|
||||
</Text>
|
||||
<Text size="1" className="text-[--gray-10]">
|
||||
支持 .zip 格式的主题包
|
||||
</Text>
|
||||
</label>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
取消
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Box>
|
||||
);
|
||||
});
|
342
frontend/app/dashboard/users.tsx
Normal file
342
frontend/app/dashboard/users.tsx
Normal file
@ -0,0 +1,342 @@
|
||||
import { Template } from "interface/template";
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
Flex,
|
||||
Table,
|
||||
Button,
|
||||
TextField,
|
||||
ScrollArea,
|
||||
Dialog,
|
||||
Avatar,
|
||||
DropdownMenu,
|
||||
Badge
|
||||
} from "@radix-ui/themes";
|
||||
import {
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
Pencil1Icon,
|
||||
TrashIcon,
|
||||
PersonIcon,
|
||||
DotsHorizontalIcon,
|
||||
LockClosedIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import type { User } from "interface/fields";
|
||||
|
||||
// 模拟用户数据
|
||||
const mockUsers: (User & { id: number })[] = [
|
||||
{
|
||||
id: 1,
|
||||
username: "admin",
|
||||
email: "admin@example.com",
|
||||
avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=1",
|
||||
role: "admin",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-03-15"),
|
||||
lastLoginAt: new Date("2024-03-15"),
|
||||
passwordHash: "",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "editor",
|
||||
email: "editor@example.com",
|
||||
avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
|
||||
role: "editor",
|
||||
createdAt: new Date("2024-02-01"),
|
||||
updatedAt: new Date("2024-03-14"),
|
||||
lastLoginAt: new Date("2024-03-14"),
|
||||
passwordHash: "",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: "user",
|
||||
email: "user@example.com",
|
||||
avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=3",
|
||||
role: "user",
|
||||
createdAt: new Date("2024-03-01"),
|
||||
updatedAt: new Date("2024-03-13"),
|
||||
lastLoginAt: new Date("2024-03-13"),
|
||||
passwordHash: "",
|
||||
}
|
||||
];
|
||||
|
||||
export default new Template({}, ({ http, args }) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<typeof mockUsers[0] | null>(null);
|
||||
const [newUserData, setNewUserData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "user"
|
||||
});
|
||||
|
||||
// 获取角色标签样式
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch(role) {
|
||||
case 'admin':
|
||||
return 'red';
|
||||
case 'editor':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 页面标题和操作栏 */}
|
||||
<Flex justify="between" align="center" className="mb-6">
|
||||
<Box>
|
||||
<Heading size="6" className="text-[--gray-12] mb-2">
|
||||
用户管理
|
||||
</Heading>
|
||||
<Text className="text-[--gray-11]">
|
||||
共 {mockUsers.length} 个用户
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
className="bg-[--accent-9]"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
新建用户
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<Box className="w-full sm:w-64 mb-6">
|
||||
<TextField.Root
|
||||
placeholder="搜索用户..."
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
|
||||
>
|
||||
<TextField.Slot>
|
||||
<MagnifyingGlassIcon height="16" width="16" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<Box className="border border-[--gray-6] rounded-lg overflow-hidden">
|
||||
<ScrollArea>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>用户</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>邮箱</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>角色</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>注册时间</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>最后登录</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>操作</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{mockUsers.map((user) => (
|
||||
<Table.Row key={user.id}>
|
||||
<Table.Cell>
|
||||
<Flex align="center" gap="2">
|
||||
<Avatar
|
||||
src={user.avatarUrl}
|
||||
fallback={user.username[0].toUpperCase()}
|
||||
size="2"
|
||||
radius="full"
|
||||
/>
|
||||
<Text weight="medium">{user.username}</Text>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{user.email}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge color={getRoleBadgeColor(user.role)}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{user.createdAt.toLocaleDateString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{user.lastLoginAt?.toLocaleDateString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex gap="2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="1"
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil1Icon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="1" color="red">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
{/* 新建用户对话框 */}
|
||||
<Dialog.Root open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<Dialog.Content style={{ maxWidth: 450 }}>
|
||||
<Dialog.Title>新建用户</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
创建一个新的用户账号
|
||||
</Dialog.Description>
|
||||
|
||||
<Flex direction="column" gap="4">
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
用户名
|
||||
</Text>
|
||||
<TextField.Root
|
||||
placeholder="输入用户名"
|
||||
value={newUserData.username}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNewUserData({...newUserData, username: e.target.value})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
邮箱
|
||||
</Text>
|
||||
<TextField.Root
|
||||
placeholder="输入邮箱"
|
||||
value={newUserData.email}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNewUserData({...newUserData, email: e.target.value})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
密码
|
||||
</Text>
|
||||
<TextField.Root
|
||||
type="password"
|
||||
placeholder="输入密码"
|
||||
value={newUserData.password}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNewUserData({...newUserData, password: e.target.value})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
角色
|
||||
</Text>
|
||||
<select
|
||||
className="w-full h-9 px-3 rounded-md bg-[--gray-1] border border-[--gray-6] text-[--gray-12]"
|
||||
value={newUserData.role}
|
||||
onChange={(e) => setNewUserData({...newUserData, role: e.target.value})}
|
||||
>
|
||||
<option value="user">普通用户</option>
|
||||
<option value="editor">编辑</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
取消
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Close>
|
||||
<Button className="bg-[--accent-9]">
|
||||
创建
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
{/* 编辑用户对话框 */}
|
||||
<Dialog.Root open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<Dialog.Content style={{ maxWidth: 450 }}>
|
||||
{selectedUser && (
|
||||
<>
|
||||
<Dialog.Title>编辑用户</Dialog.Title>
|
||||
<Dialog.Description size="2" mb="4">
|
||||
修改用户信息
|
||||
</Dialog.Description>
|
||||
|
||||
<Flex direction="column" gap="4">
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
用户名
|
||||
</Text>
|
||||
<TextField.Root
|
||||
defaultValue={selectedUser.username}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
邮箱
|
||||
</Text>
|
||||
<TextField.Root
|
||||
defaultValue={selectedUser.email}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
角色
|
||||
</Text>
|
||||
<select
|
||||
className="w-full h-9 px-3 rounded-md bg-[--gray-1] border border-[--gray-6] text-[--gray-12]"
|
||||
defaultValue={selectedUser.role}
|
||||
>
|
||||
<option value="user">普通用户</option>
|
||||
<option value="editor">编辑</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text as="label" size="2" weight="bold" className="block mb-2">
|
||||
重置密码
|
||||
</Text>
|
||||
<TextField.Root
|
||||
type="password"
|
||||
placeholder="留空则不修改"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<Dialog.Close>
|
||||
<Button variant="soft" color="gray">
|
||||
取消
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Close>
|
||||
<Button className="bg-[--accent-9]">
|
||||
保存
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Box>
|
||||
);
|
||||
});
|
@ -5,43 +5,92 @@ import about from "themes/echoes/about";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import post from "themes/echoes/post";
|
||||
import { memo, useCallback } from "react";
|
||||
import login from "~/dashboard/login";
|
||||
import adminLayout from "~/dashboard/layout";
|
||||
import dashboard from "~/dashboard/index";
|
||||
import posts from "~/dashboard/posts";
|
||||
import comments from "~/dashboard/comments";
|
||||
import categories from "./dashboard/categories";
|
||||
import settings from "./dashboard/settings";
|
||||
import files from "./dashboard/files";
|
||||
import themes from "./dashboard/themes";
|
||||
import users from "~/dashboard/users";
|
||||
import plugins from "./dashboard/plugins";
|
||||
|
||||
const args = {
|
||||
title: "我的页面",
|
||||
theme: "dark",
|
||||
nav: '<a href="/">index</a><a href="/error">error</a><a href="/about">about</a><a href="/post">post</a>',
|
||||
nav: '<a href="/">index</a><a href="/error">error</a><a href="/about">about</a><a href="/post">post</a><a href="/login">login</a><a href="/dashboard">dashboard</a>',
|
||||
} as const;
|
||||
|
||||
const renderLayout = (children: React.ReactNode) => {
|
||||
return layout.render({
|
||||
children,
|
||||
args,
|
||||
});
|
||||
// 创建布局渲染器的工厂函数
|
||||
const createLayoutRenderer = (layoutComponent: any) => {
|
||||
return (children: React.ReactNode) => {
|
||||
return layoutComponent.render({
|
||||
children,
|
||||
args,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// 使用工厂函数创建不同的布局渲染器
|
||||
const renderLayout = createLayoutRenderer(layout);
|
||||
const renderDashboardLayout = createLayoutRenderer(adminLayout);
|
||||
|
||||
const Routes = memo(() => {
|
||||
const location = useLocation();
|
||||
const path = location.pathname.split("/")[1];
|
||||
const [mainPath, subPath] = location.pathname.split("/").filter(Boolean);
|
||||
|
||||
// 使用 useCallback 缓存渲染函数
|
||||
const renderContent = useCallback((Component: any) => {
|
||||
return renderLayout(Component.render(args));
|
||||
}, []);
|
||||
|
||||
// 根据路径返回对应组件
|
||||
if (path === "error") {
|
||||
return renderContent(ErrorPage);
|
||||
}
|
||||
// 添加管理后台内容渲染函数
|
||||
const renderDashboardContent = useCallback((Component: any) => {
|
||||
return renderDashboardLayout(Component.render(args));
|
||||
}, []);
|
||||
|
||||
if (path === "about") {
|
||||
return renderContent(about);
|
||||
}
|
||||
// 前台路由
|
||||
switch (mainPath) {
|
||||
case "error":
|
||||
return renderContent(ErrorPage);
|
||||
case "about":
|
||||
return renderContent(about);
|
||||
case "post":
|
||||
return renderContent(post);
|
||||
case "login":
|
||||
return login.render(args);
|
||||
case "dashboard":
|
||||
// 管理后台路由
|
||||
if (!subPath) {
|
||||
return renderDashboardContent(dashboard);
|
||||
}
|
||||
|
||||
if (path === "post") {
|
||||
return renderContent(post);
|
||||
// 根据子路径返回对应的管理页面
|
||||
switch (subPath) {
|
||||
case "posts":
|
||||
return renderDashboardContent(posts);
|
||||
case "comments":
|
||||
return renderDashboardContent(comments);
|
||||
case "categories":
|
||||
return renderDashboardContent(categories);
|
||||
case "files":
|
||||
return renderDashboardContent(files);
|
||||
case "settings":
|
||||
return renderDashboardContent(settings);
|
||||
case "themes":
|
||||
return renderDashboardContent(themes);
|
||||
case "users":
|
||||
return renderDashboardContent(users);
|
||||
case "plugins":
|
||||
return renderDashboardContent(plugins);
|
||||
default:
|
||||
return renderDashboardContent(<div>404 未找到页面</div>);
|
||||
}
|
||||
default:
|
||||
return renderContent(article);
|
||||
}
|
||||
|
||||
return renderContent(article);
|
||||
});
|
||||
|
||||
export default Routes;
|
||||
|
125
frontend/hooks/Background.tsx
Normal file
125
frontend/hooks/Background.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, memo } from 'react';
|
||||
import { useThemeMode } from 'hooks/ThemeMode';
|
||||
|
||||
interface AnimatedBackgroundProps {
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
export const AnimatedBackground = memo(({ onError }: AnimatedBackgroundProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { mode } = useThemeMode();
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
onError?.();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = canvas.getContext('2d', {
|
||||
alpha: true,
|
||||
desynchronized: true
|
||||
});
|
||||
|
||||
if (!ctx) {
|
||||
console.error('无法获取 canvas context');
|
||||
onError?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加非空断言
|
||||
const context = ctx!;
|
||||
|
||||
// 添加必要的变量定义
|
||||
const getRandomHSLColor = () => {
|
||||
const hue = Math.random() * 360;
|
||||
const saturation = 70 + Math.random() * 30;
|
||||
const lightness = mode === 'dark' ? 40 + Math.random() * 20 : 60 + Math.random() * 20;
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
|
||||
const ballColor = getRandomHSLColor();
|
||||
let ballRadius = 100;
|
||||
let x = canvas.width / 2;
|
||||
let y = canvas.height - 200;
|
||||
let dx = 0.2;
|
||||
let dy = -0.2;
|
||||
|
||||
// 添加 drawBall 函数
|
||||
function drawBall() {
|
||||
context.beginPath();
|
||||
context.arc(x, y, ballRadius, 0, Math.PI * 2);
|
||||
context.fillStyle = ballColor;
|
||||
context.fill();
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
// 设置 canvas 尺寸
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
|
||||
// 性能优化:降低动画帧率
|
||||
const fps = 30;
|
||||
const interval = 1000 / fps;
|
||||
let then = Date.now();
|
||||
|
||||
const draw = () => {
|
||||
const now = Date.now();
|
||||
const delta = now - then;
|
||||
|
||||
if (delta > interval) {
|
||||
// 更新时间戳
|
||||
then = now - (delta % interval);
|
||||
|
||||
// 绘制逻辑...
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawBall();
|
||||
|
||||
if (x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
|
||||
dx = -dx;
|
||||
}
|
||||
if (y + dy > canvas.height - ballRadius || y + dy < ballRadius) {
|
||||
dy = -dy;
|
||||
}
|
||||
|
||||
x += dx;
|
||||
y += dy;
|
||||
}
|
||||
|
||||
// 使用 requestAnimationFrame 代替 setInterval
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
let animationFrameId: number;
|
||||
draw();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Canvas 初始化失败:', error);
|
||||
onError?.();
|
||||
return;
|
||||
}
|
||||
}, [mode, onError]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full opacity-50"
|
||||
style={{
|
||||
filter: 'blur(150px)',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
willChange: 'transform'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -210,11 +210,6 @@ interface ParticleImageProps {
|
||||
|
||||
// 修改 BG_CONFIG,添加尺寸配置
|
||||
const BG_CONFIG = {
|
||||
colors: {
|
||||
from: 'rgb(10,37,77)',
|
||||
via: 'rgb(8,27,57)',
|
||||
to: 'rgb(2,8,23)'
|
||||
},
|
||||
className: 'bg-gradient-to-br from-[rgb(248,250,252)] via-[rgb(241,245,249)] to-[rgb(236,241,247)] dark:from-[rgb(10,37,77)] dark:via-[rgb(8,27,57)] dark:to-[rgb(2,8,23)]'
|
||||
};
|
||||
|
||||
@ -375,6 +370,12 @@ export const ParticleImage = ({
|
||||
const cleanup = useCallback(() => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 检查是否应该跳过清理
|
||||
if (sceneRef.current?.userData.isSmileComplete ||
|
||||
sceneRef.current?.userData.isErrorComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理动画状态
|
||||
isAnimatingRef.current = false;
|
||||
|
||||
@ -394,43 +395,16 @@ export const ParticleImage = ({
|
||||
|
||||
// 清理场景资源
|
||||
if (sceneRef.current) {
|
||||
// 遍历场景中的所有对象
|
||||
sceneRef.current.traverse((object) => {
|
||||
if (object instanceof THREE.Points) {
|
||||
const geometry = object.geometry;
|
||||
const material = object.material as THREE.PointsMaterial;
|
||||
|
||||
// 清理几何体
|
||||
if (geometry) {
|
||||
// 清空缓冲区数据
|
||||
if (geometry.attributes.position) {
|
||||
geometry.attributes.position.array = new Float32Array(0);
|
||||
}
|
||||
if (geometry.attributes.color) {
|
||||
geometry.attributes.color.array = new Float32Array(0);
|
||||
}
|
||||
|
||||
// 移除所有属性
|
||||
geometry.deleteAttribute('position');
|
||||
geometry.deleteAttribute('color');
|
||||
geometry.dispose();
|
||||
}
|
||||
|
||||
// 清理材质
|
||||
if (material) {
|
||||
material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 清空场景
|
||||
while(sceneRef.current.children.length > 0) {
|
||||
sceneRef.current.remove(sceneRef.current.children[0]);
|
||||
// 检查是否应该跳过清理
|
||||
if (!sceneRef.current.userData.isSmileComplete &&
|
||||
!sceneRef.current.userData.isErrorComplete) {
|
||||
cleanupResources(sceneRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
// 修改渲染器清理逻辑
|
||||
if (rendererRef.current) {
|
||||
if (rendererRef.current && !sceneRef.current?.userData.isSmileComplete &&
|
||||
!sceneRef.current?.userData.isErrorComplete) {
|
||||
const renderer = rendererRef.current;
|
||||
|
||||
// 确保在移除 DOM 元素前停止渲染
|
||||
@ -459,7 +433,8 @@ export const ParticleImage = ({
|
||||
}
|
||||
|
||||
// 清理相机引用
|
||||
if (cameraRef.current) {
|
||||
if (cameraRef.current && !sceneRef.current?.userData.isSmileComplete &&
|
||||
!sceneRef.current?.userData.isErrorComplete) {
|
||||
cameraRef.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
@ -475,7 +450,10 @@ export const ParticleImage = ({
|
||||
const updateParticles = useCallback((width: number, height: number) => {
|
||||
if (!sceneRef.current || isAnimatingRef.current || !isMountedRef.current) return;
|
||||
|
||||
cleanup();
|
||||
// 只有当src不为空时才执行cleanup
|
||||
if(src !== '') {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
@ -500,8 +478,40 @@ export const ParticleImage = ({
|
||||
sceneRef.current.add(points);
|
||||
|
||||
const positionAttribute = geometry.attributes.position as THREE.BufferAttribute;
|
||||
startAnimation(positionAttribute, particles, width, height);
|
||||
}, [cleanup, startAnimation]);
|
||||
|
||||
// 记录完成的动画数量
|
||||
let completedAnimations = 0;
|
||||
const totalAnimations = particles.length;
|
||||
|
||||
particles.forEach((particle, i) => {
|
||||
const i3 = i * 3;
|
||||
const distanceToCenter = Math.sqrt(
|
||||
Math.pow(particle.originalX, 2) +
|
||||
Math.pow(particle.originalY, 2)
|
||||
);
|
||||
const maxDistance = Math.sqrt(Math.pow(width/2, 2) + Math.pow(height/2, 2));
|
||||
const normalizedDistance = distanceToCenter / maxDistance;
|
||||
|
||||
gsap.to(positionAttribute.array, {
|
||||
duration: 0.8,
|
||||
delay: normalizedDistance * 0.6,
|
||||
[i3]: particle.originalX,
|
||||
[i3 + 1]: particle.originalY,
|
||||
[i3 + 2]: 0,
|
||||
ease: "sine.inOut",
|
||||
onUpdate: () => {
|
||||
positionAttribute.needsUpdate = true;
|
||||
},
|
||||
onComplete: () => {
|
||||
completedAnimations++;
|
||||
// 当所有动画完成时设置标记
|
||||
if (completedAnimations === totalAnimations && sceneRef.current) {
|
||||
sceneRef.current.userData.isSmileComplete = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [cleanup, src]);
|
||||
|
||||
// 将 resize 处理逻辑移到组件顶层
|
||||
const handleResize = useCallback(() => {
|
||||
@ -511,7 +521,7 @@ export const ParticleImage = ({
|
||||
const width = containerRef.current.offsetWidth;
|
||||
const height = containerRef.current.offsetHeight;
|
||||
|
||||
// 更新相机视图
|
||||
// 更新相机图
|
||||
const camera = cameraRef.current;
|
||||
camera.left = width / -2;
|
||||
camera.right = width / 2;
|
||||
@ -563,8 +573,19 @@ export const ParticleImage = ({
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: window.innerWidth > 768,
|
||||
powerPreference: 'low-power'
|
||||
powerPreference: 'low-power',
|
||||
failIfMajorPerformanceCaveat: false,
|
||||
canvas: document.createElement('canvas')
|
||||
});
|
||||
|
||||
// 在初始化渲染器后立即添加错误检查
|
||||
if (!renderer.capabilities.isWebGL2) {
|
||||
console.warn('WebGL2 not supported, falling back...');
|
||||
renderer.dispose();
|
||||
renderer.forceContextLoss();
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(
|
||||
window.devicePixelRatio,
|
||||
window.innerWidth <= 768 ? 2 : 3
|
||||
@ -572,12 +593,17 @@ export const ParticleImage = ({
|
||||
renderer.setSize(width, height);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// 确保容器仍然存在再添加渲染器
|
||||
if (containerRef.current && isMountedRef.current) {
|
||||
containerRef.current.appendChild(renderer.domElement);
|
||||
// 修改渲染器添加到DOM的部分
|
||||
if (containerRef.current && isMountedRef.current && renderer.domElement) {
|
||||
try {
|
||||
containerRef.current.appendChild(renderer.domElement);
|
||||
} catch (e) {
|
||||
console.warn('Failed to append renderer:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否应该显示笑
|
||||
// 检查是否应该显示笑脸
|
||||
if (src === '') {
|
||||
const { particles, positionArray, colorArray, particleSize } = createSmileParticles(width, height);
|
||||
|
||||
@ -599,10 +625,10 @@ export const ParticleImage = ({
|
||||
const points = new THREE.Points(geometry, material);
|
||||
scene.add(points);
|
||||
|
||||
// 修改动画效果
|
||||
const positionAttribute = geometry.attributes.position;
|
||||
// 添加这一行来获取position属性
|
||||
const positionAttribute = geometry.attributes.position as THREE.BufferAttribute;
|
||||
|
||||
// 算到中心的距离用于延迟
|
||||
// 修改动画效果,添加完成回调
|
||||
particles.forEach((particle, i) => {
|
||||
const i3 = i * 3;
|
||||
const distanceToCenter = Math.sqrt(
|
||||
@ -621,6 +647,12 @@ export const ParticleImage = ({
|
||||
ease: "sine.inOut",
|
||||
onUpdate: () => {
|
||||
positionAttribute.needsUpdate = true;
|
||||
},
|
||||
onComplete: () => {
|
||||
// 动画完成后设置标记,防止被清理
|
||||
if(scene) {
|
||||
scene.userData.isSmileComplete = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -699,6 +731,12 @@ export const ParticleImage = ({
|
||||
ease: "back.out(1.7)",
|
||||
onUpdate: () => {
|
||||
positionAttribute.needsUpdate = true;
|
||||
},
|
||||
onComplete: () => {
|
||||
// 添加标记表示错误动画已完成
|
||||
if(scene) {
|
||||
scene.userData.isErrorComplete = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -785,7 +823,7 @@ export const ParticleImage = ({
|
||||
delay: normalizedDistance * 0.3
|
||||
});
|
||||
|
||||
// 随机初始位置(根据距离调整范围)
|
||||
// 随机初始位置(根据距离调范围)
|
||||
const spread = 1 - normalizedDistance * 0.5; // 距离越远,始扩散越小
|
||||
positionArray.push(
|
||||
(Math.random() - 0.5) * width * spread,
|
||||
@ -962,7 +1000,7 @@ export const ImageLoader = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [animationComplete, setAnimationComplete] = useState(false);
|
||||
|
||||
// 处理图片预加载
|
||||
// 处理图片加载
|
||||
const preloadImage = useCallback(() => {
|
||||
if (!src || loadingRef.current) return;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import "styles/echoes.css";
|
||||
|
||||
export const Echoes: React.FC = () => {
|
||||
|
@ -11,5 +11,6 @@ export interface ThemeConfig {
|
||||
layout?: string;
|
||||
configuration: Configuration;
|
||||
error?: string;
|
||||
manage?: string;
|
||||
routes: Map<string, string>;
|
||||
}
|
||||
|
@ -148,7 +148,7 @@ export default new Template({}, ({ http, args }) => {
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="social-link p-3 rounded-full hover:bg-[--gray-3] transition-colors"
|
||||
className="social-link hover-bg p-3 rounded-full"
|
||||
>
|
||||
{link.icon}
|
||||
</Link>
|
||||
|
@ -2,7 +2,6 @@ import { Template } from "interface/template";
|
||||
import { Container, Heading, Text, Flex, Card, Button, ScrollArea } from "@radix-ui/themes";
|
||||
import {
|
||||
CalendarIcon,
|
||||
PersonIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
@ -20,7 +19,7 @@ const mockArticles: PostDisplay[] = [
|
||||
content: "在现代前端开发中,一个高效的工作流程对于提高开发效率至关重要...",
|
||||
authorName: "张三",
|
||||
publishedAt: new Date("2024-03-15"),
|
||||
coverImage: "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=500&auto=format",
|
||||
coverImage: "https://avatars.githubusercontent.com/u/72159?v=4",
|
||||
metaKeywords: "",
|
||||
metaDescription: "",
|
||||
status: "published",
|
||||
@ -42,7 +41,7 @@ const mockArticles: PostDisplay[] = [
|
||||
content: "React 18 带来了许多令人兴奋的新特性,包括并发渲染、自动批处理更新...",
|
||||
authorName: "李四",
|
||||
publishedAt: new Date("2024-03-14"),
|
||||
coverImage: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=500&auto=format",
|
||||
coverImage: "",
|
||||
metaKeywords: "",
|
||||
metaDescription: "",
|
||||
status: "published",
|
||||
@ -63,7 +62,7 @@ const mockArticles: PostDisplay[] = [
|
||||
content: "在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...",
|
||||
authorName: "王五",
|
||||
publishedAt: new Date("2024-03-13"),
|
||||
coverImage: "https://images.unsplash.com/photo-1592609931095-54a2168ae893?w=500&auto=format",
|
||||
coverImage: "ssssxx",
|
||||
metaKeywords: "",
|
||||
metaDescription: "",
|
||||
status: "published",
|
||||
@ -84,7 +83,7 @@ const mockArticles: PostDisplay[] = [
|
||||
content: "移动端开发中的各种适配问题及解决方案...",
|
||||
authorName: "田六",
|
||||
publishedAt: new Date("2024-03-13"),
|
||||
coverImage: "https://images.unsplash.com/photo-1526498460520-4c246339dccb?w=500&auto=format",
|
||||
coverImage: "https://images.unsplash.com/photo-1537432376769-00f5c2f4c8d2?w=500&auto=format",
|
||||
metaKeywords: "",
|
||||
metaDescription: "",
|
||||
status: "published",
|
||||
@ -157,7 +156,7 @@ const mockArticles: PostDisplay[] = [
|
||||
{
|
||||
id: 7,
|
||||
title: "Web 性能优化:从理论到实践",
|
||||
content: "全面解析 Web 性能优化策略,包括资源加载优化、渲染性能优化、网络优化等多个维度...",
|
||||
content: "全面解析 Web 性能优化策略,包括资源加载优化、渲染性能优化、网络优化等多个...",
|
||||
authorName: "周九",
|
||||
publishedAt: new Date("2024-03-10"),
|
||||
coverImage: "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=500&auto=format",
|
||||
@ -173,7 +172,7 @@ const mockArticles: PostDisplay[] = [
|
||||
],
|
||||
tags: [
|
||||
{ name: "性能监控" },
|
||||
{ name: "懒加<EFBFBD><EFBFBD><EFBFBD>" },
|
||||
{ name: "懒加载" },
|
||||
{ name: "缓存策略" },
|
||||
{ name: "代码分割" }
|
||||
]
|
||||
@ -245,10 +244,7 @@ export default new Template({}, ({ http, args }) => {
|
||||
{articleData.map((article) => (
|
||||
<Card
|
||||
key={article.id}
|
||||
className="group cursor-pointer transition-all duration-300
|
||||
bg-[--card-bg] border-[--border-color]
|
||||
hover:shadow-lg hover:shadow-[--card-bg]/10
|
||||
hover:border-[--accent-9]/50"
|
||||
className="group cursor-pointer hover-card border border-[--gray-a3]"
|
||||
>
|
||||
<div className="p-4 relative flex flex-col gap-4">
|
||||
<div className="flex gap-4">
|
||||
@ -280,7 +276,11 @@ export default new Template({}, ({ http, args }) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<ScrollArea type="hover" scrollbars="horizontal" className="flex-1">
|
||||
<ScrollArea
|
||||
type="hover"
|
||||
scrollbars="horizontal"
|
||||
className="scroll-container flex-1"
|
||||
>
|
||||
<Flex gap="2" className="flex-nowrap">
|
||||
{article.categories?.map((category) => (
|
||||
<Text
|
||||
|
@ -18,7 +18,12 @@ import parse from 'html-react-parser';
|
||||
|
||||
// 直接导出 Layout 实例
|
||||
export default new Layout(({ children, args }) => {
|
||||
const [moreState, setMoreState] = useState(false);
|
||||
const [moreState, setMoreState] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth >= 1024 ? false : false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const [loginState, setLoginState] = useState(true);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
|
||||
@ -73,6 +78,121 @@ export default new Layout(({ children, args }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 修改移动端菜单的渲染逻辑
|
||||
const mobileMenu = (
|
||||
<Box className="flex lg:hidden gap-2 items-center">
|
||||
{/* 添加移动端进度指示器 */}
|
||||
<Box
|
||||
className={`w-10 h-10 flex items-center justify-center ${
|
||||
scrollProgress > 0
|
||||
? 'block'
|
||||
: 'hidden'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-10 h-10 p-0 text-[--gray-12] hover:text-[--accent-9] transition-colors flex items-center justify-center [&_text]:text-[--gray-12] [&_text:hover]:text-[--accent-9]"
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<text
|
||||
x="50"
|
||||
y="55"
|
||||
className="progress-indicator font-bold transition-colors"
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '56px',
|
||||
fill: 'currentColor'
|
||||
}}
|
||||
>
|
||||
{Math.round(scrollProgress)}
|
||||
</text>
|
||||
</svg>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
className="w-10 h-10 p-0 hover:text-[--accent-9] transition-colors flex items-center justify-center group bg-transparent border-0"
|
||||
onClick={() => setMoreState(!moreState)}
|
||||
>
|
||||
{moreState ? (
|
||||
<Cross1Icon className="h-5 w-5 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
) : (
|
||||
<HamburgerMenuIcon className="h-5 w-5 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 移动端菜单内容 */}
|
||||
{moreState && (
|
||||
<div
|
||||
className="absolute top-full right-4 w-[180px] mt-2 rounded-md bg-[--gray-1] border border-[--gray-a5] shadow-lg
|
||||
animate-in fade-in-0 zoom-in-95 slide-in-from-top-2
|
||||
duration-200 z-[90]"
|
||||
>
|
||||
<Box className="flex flex-col">
|
||||
{/* 导航链接区域 */}
|
||||
<Box className="flex flex-col">
|
||||
<Box className="flex flex-col [&>a]:px-4 [&>a]:py-2.5 [&>a]:text-[--gray-12] [&>a]:transition-colors [&>a:hover]:bg-[--gray-a3] [&>a]:text-lg [&>a]:text-center [&>a]:border-b [&>a]:border-[--gray-a5] [&>a:first-child]:rounded-t-md [&>a:last-child]:border-b-0">
|
||||
{parse(navString)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 搜索框区域 */}
|
||||
<Box className="p-4 border-t border-[--gray-a5]">
|
||||
<TextField.Root
|
||||
size="2"
|
||||
variant="surface"
|
||||
placeholder="搜索..."
|
||||
className="w-full [&_input]:pl-3 hover:border-[--accent-9] border transition-colors group"
|
||||
>
|
||||
<TextField.Slot
|
||||
side="right"
|
||||
className="p-2"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
|
||||
{/* 用户操作区域 */}
|
||||
<Box className="p-4 border-t border-[--gray-a5]">
|
||||
<Flex gap="3" align="center">
|
||||
{/* 用户信息/登录按钮 - 调整为 70% 宽度 */}
|
||||
<Box className="w-[70%]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 text-[--gray-12] hover:text-[--accent-9] hover:bg-[--gray-a3] transition-colors"
|
||||
>
|
||||
{loginState ? (
|
||||
<>
|
||||
<AvatarIcon className="w-5 h-5" />
|
||||
<span>个人中心</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PersonIcon className="w-5 h-5" />
|
||||
<span>登录/注册</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 主题切换按钮 - 调整为 30% 宽度 */}
|
||||
<Box className="w-[30%] flex justify-end [&_button]:w-10 [&_button]:h-10 [&_svg]:w-5 [&_svg]:h-5 [&_button]:text-[--gray-12] [&_button:hover]:text-[--accent-9]">
|
||||
<ThemeModeToggle />
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Theme
|
||||
grayColor="gray"
|
||||
@ -87,7 +207,7 @@ export default new Layout(({ children, args }) => {
|
||||
{/* 导航栏 */}
|
||||
<Box
|
||||
asChild
|
||||
className="w-full backdrop-blur-sm border-b border-[--gray-a5] z-60 sticky top-0"
|
||||
className="w-full backdrop-blur-sm border-b border-[--gray-a5] z-[100] sticky top-0"
|
||||
>
|
||||
<nav>
|
||||
<Container size="4">
|
||||
@ -100,7 +220,7 @@ export default new Layout(({ children, args }) => {
|
||||
<Flex align="center">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center group transition-all"
|
||||
className="hover-text flex items-center"
|
||||
>
|
||||
<Box className="w-20 h-20 [&_path]:transition-all [&_path]:duration-200 group-hover:[&_path]:stroke-[--accent-9]">
|
||||
<Echoes />
|
||||
@ -221,121 +341,7 @@ export default new Layout(({ children, args }) => {
|
||||
</Box>
|
||||
|
||||
{/* 移动菜单按钮 */}
|
||||
<Box className="flex lg:hidden gap-2 items-center">
|
||||
{/* 添加移动端进度指示器 */}
|
||||
<Box
|
||||
className={`w-10 h-10 flex items-center justify-center ${
|
||||
scrollProgress > 0
|
||||
? 'block'
|
||||
: 'hidden'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-10 h-10 p-0 text-[--gray-12] hover:text-[--accent-9] transition-colors flex items-center justify-center [&_text]:text-[--gray-12] [&_text:hover]:text-[--accent-9]"
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<text
|
||||
x="50"
|
||||
y="55"
|
||||
className="progress-indicator font-bold transition-colors"
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '56px',
|
||||
fill: 'currentColor'
|
||||
}}
|
||||
>
|
||||
{Math.round(scrollProgress)}
|
||||
</text>
|
||||
</svg>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<DropdownMenuPrimitive.Root
|
||||
open={moreState}
|
||||
onOpenChange={setMoreState}
|
||||
>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<Button
|
||||
className="w-10 h-10 p-0 hover:text-[--accent-9] transition-colors flex items-center justify-center group bg-transparent border-0"
|
||||
>
|
||||
{moreState ? (
|
||||
<Cross1Icon className="h-5 w-5 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
) : (
|
||||
<HamburgerMenuIcon className="h-5 w-5 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
align="end"
|
||||
sideOffset={5}
|
||||
className="mt-2 min-w-[280px] rounded-md bg-[--gray-1] border border-[--gray-a5] shadow-lg animate-in fade-in slide-in-from-top-2"
|
||||
>
|
||||
<Box className="flex flex-col">
|
||||
{/* 导航链接区域 */}
|
||||
<Box className="flex flex-col">
|
||||
<Box className="flex flex-col [&>a]:px-4 [&>a]:py-2.5 [&>a]:text-[--gray-12] [&>a]:transition-colors [&>a:hover]:bg-[--gray-a3] [&>a]:text-lg [&>a]:text-center [&>a]:border-b [&>a]:border-[--gray-a5] [&>a:first-child]:rounded-t-md [&>a:last-child]:border-b-0">
|
||||
{parse(navString)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 搜索框区域 */}
|
||||
<Box className="p-4 border-t border-[--gray-a5]">
|
||||
<TextField.Root
|
||||
size="2"
|
||||
variant="surface"
|
||||
placeholder="搜索..."
|
||||
className="w-full [&_input]:pl-3 hover:border-[--accent-9] border transition-colors group"
|
||||
>
|
||||
<TextField.Slot
|
||||
side="right"
|
||||
className="p-2"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
</Box>
|
||||
|
||||
{/* 用户操作区域 */}
|
||||
<Box className="p-4 border-t border-[--gray-a5]">
|
||||
<Flex gap="3" align="center">
|
||||
{/* 用户信息/登录按钮 - 占据 55% 宽度 */}
|
||||
<Box className="w-[55%]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 text-[--gray-12] hover:text-[--accent-9] hover:bg-[--gray-a3] transition-colors"
|
||||
>
|
||||
{loginState ? (
|
||||
<>
|
||||
<AvatarIcon className="w-5 h-5" />
|
||||
<span>个人中心</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PersonIcon className="w-5 h-5" />
|
||||
<span>登录/注册</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 主题切换按钮 - 占据剩余空间 */}
|
||||
<Box className="flex-1 flex justify-end [&_button]:w-10 [&_button]:h-10 [&_svg]:w-5 [&_svg]:h-5 [&_button]:text-[--gray-12] [&_button:hover]:text-[--accent-9]">
|
||||
<ThemeModeToggle />
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
</Box>
|
||||
{mobileMenu}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState,useContext, useCallback, useRef, useEffect } from "react";
|
||||
import React, { useMemo, useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Template } from "interface/template";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
@ -19,287 +19,222 @@ import { getColorScheme } from "themes/echoes/utils/colorScheme";
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Components } from "react-markdown";
|
||||
import { toast } from "hooks/Notification";
|
||||
|
||||
// 示例文章数据
|
||||
const mockPost: PostDisplay = {
|
||||
id: 1,
|
||||
title: "现代前端开发完全指南",
|
||||
title: "Markdown 完全指南:从基础到高级排版",
|
||||
content: `
|
||||
# 现代前端开发完全指南
|
||||
# Markdown 完全指南:从基础到高级排版
|
||||
|
||||
前端开发已经成为软件开发中最重要的领域之一。本全面介绍现代前端开发的各个方面。
|
||||
这篇指南将介绍 Markdown 的基础语法和高级排版技巧。
|
||||
|
||||
![Modern Frontend Development](https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=600)
|
||||
## 1. 基础语法
|
||||
|
||||
## 1. 开发环境搭建
|
||||
### 1.1 文本格式化
|
||||
|
||||
在开始前端开发之前,我们要搭建合适的开发环境。
|
||||
普通文本不需要任何特殊标记。
|
||||
|
||||
### 1.1 必备工具安装
|
||||
**这是粗体文本**
|
||||
*这是斜体文本*
|
||||
***这是粗斜体文本***
|
||||
~~这是删除线文本~~
|
||||
|
||||
开发环境需要安装以下工具:
|
||||
### 1.2 列表
|
||||
|
||||
\`\`\`bash
|
||||
# 安装 Node.js
|
||||
brew install node
|
||||
#### 无序列表:
|
||||
- 第一项
|
||||
- 子项 1
|
||||
- 子项 2
|
||||
- 第二项
|
||||
- 第三项
|
||||
|
||||
# 安装包管理器
|
||||
npm install -g pnpm
|
||||
#### 有序列表:
|
||||
1. 第一步
|
||||
1. 子步骤 1
|
||||
2. 子步骤 2
|
||||
2. 第二步
|
||||
3. 第三步
|
||||
|
||||
# 安装开发工具
|
||||
pnpm install -g typescript vite
|
||||
\`\`\`
|
||||
#### 任务列表:
|
||||
- [x] 已完成任务
|
||||
- [ ] 未完成任务
|
||||
- [x] 又一个已完成任务
|
||||
|
||||
### 1.2 编辑器配置
|
||||
### 1.3 代码展示
|
||||
|
||||
推荐使用 VS Code 作为开发工具,需要安装以下插件:
|
||||
|
||||
- ESLint
|
||||
- Prettier
|
||||
- TypeScript Vue Plugin
|
||||
- Tailwind CSS IntelliSense
|
||||
|
||||
![VS Code Setup](https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=1200&h=600)
|
||||
|
||||
## 2. 项目架构设计
|
||||
|
||||
### 2.1 目录结构
|
||||
### 2.1 目录结构
|
||||
### 2.1 目录结构
|
||||
### 2.1 目录结构
|
||||
### 2.1 目录结构
|
||||
### 2.1 目录结构
|
||||
### 2.1 目录结构
|
||||
### 2.1 目录结构
|
||||
|
||||
一个良好的项目结构对于项目的可维护性至关重要。
|
||||
行内代码:\`const greeting = "Hello World";\`
|
||||
|
||||
代码块:
|
||||
\`\`\`typescript
|
||||
// 推荐的项目结构
|
||||
interface ProjectStructure {
|
||||
src: {
|
||||
components: {
|
||||
common: string[]; // 通用组件
|
||||
features: string[]; // 功能组件
|
||||
layouts: string[]; // 布局组件
|
||||
};
|
||||
pages: string[]; // 页面组件
|
||||
hooks: string[]; // 定 hooks
|
||||
utils: string[]; // 工具函数
|
||||
types: string[]; // 类型定义
|
||||
styles: string[]; // 样式文件
|
||||
}
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
function greet(user: User): string {
|
||||
return \`Hello, \${user.name}!\`;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 2.2 状态管理
|
||||
### 1.4 表格
|
||||
|
||||
现代前端应用需要高效的状态管理方案:
|
||||
| 功能 | 基础版 | 高级版 |
|
||||
|:-----|:------:|-------:|
|
||||
| 文本编辑 | ✓ | ✓ |
|
||||
| 实时预览 | ✗ | ✓ |
|
||||
| 导出格式 | 2种 | 5种 |
|
||||
|
||||
![State Management](https://images.unsplash.com/photo-1555949963-ff9fe0c870eb?w=1200&h=600)
|
||||
## 2. 高级排版
|
||||
|
||||
## 3. 性能优化
|
||||
### 2.1 图文混排布局
|
||||
|
||||
### 3.1 加载性能
|
||||
#### 左图右文
|
||||
|
||||
关键的加载性能指标:
|
||||
<div class="flex items-center gap-6 my-8">
|
||||
<img src="https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=400&h=400"
|
||||
alt="写作工具"
|
||||
class="w-1/3 rounded-lg shadow-lg" />
|
||||
<div class="flex-1">
|
||||
<h4 class="text-xl font-bold mb-2">高效写作工具</h4>
|
||||
<p>使用合适的写作工具可以极大提升写作效率。推荐使用支持即时预览的编辑器,这样可以实时查看排版效果。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
| 指标 | 目标值 | 优化方法 |
|
||||
|------|--------|----------|
|
||||
| FCP | < 2s | 路由懒加载 |
|
||||
| TTI | < 3.5s | 代码分割 |
|
||||
| LCP | < 2.5s | 图片优化 |
|
||||
#### 右图左文
|
||||
|
||||
### 3.2 运行时性能
|
||||
<div class="flex items-center gap-6 my-8">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-xl font-bold mb-2">版面设计原则</h4>
|
||||
<p>好的版面设计应该让内容清晰易读,层次分明。合理使用留白和分隔符可以让文章更有结构感。</p>
|
||||
</div>
|
||||
<img src="https://images.unsplash.com/photo-1499951360447-b19be8fe80f5?w=400&h=400"
|
||||
alt="设计工具"
|
||||
class="w-1/3 rounded-lg shadow-lg" />
|
||||
</div>
|
||||
|
||||
#### 3.2.1 虚拟列表
|
||||
### 2.2 可折叠内容
|
||||
|
||||
处理大数据列表时的示例代码:
|
||||
<details class="my-4">
|
||||
<summary class="cursor-pointer p-4 bg-gray-100 rounded-lg font-medium hover:bg-gray-200 transition-colors">
|
||||
🎯 如何选择合适的写作工具?
|
||||
</summary>
|
||||
|
||||
\`\`\`typescript
|
||||
interface VirtualListProps {
|
||||
items: any[];
|
||||
height: number;
|
||||
itemHeight: number;
|
||||
renderItem: (item: any) => React.ReactNode;
|
||||
}
|
||||
选择写作工具时需要考虑以下几点:
|
||||
|
||||
const VirtualList: React.FC<VirtualListProps> = ({
|
||||
items,
|
||||
height,
|
||||
itemHeight,
|
||||
renderItem
|
||||
}) => {
|
||||
// 现码...
|
||||
};
|
||||
\`\`\`
|
||||
1. **跨平台支持** - 确保在不同设备上都能访问
|
||||
2. **实时预览** - Markdown 实时渲染很重要
|
||||
3. **版本控制** - 最好能支持文章的版本管理
|
||||
4. **导出功能** - 支持导出为多种格式
|
||||
</details>
|
||||
|
||||
#### 3.2.2 防抖与节流
|
||||
### 2.3 并排卡片
|
||||
|
||||
\`\`\`typescript
|
||||
// 防抖函数实现
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 my-8">
|
||||
<div class="p-6 bg-gray-100 rounded-lg">
|
||||
<h4 class="text-lg font-bold mb-2">🚀 快速上手</h4>
|
||||
<p>通过简单的标记语法,快速创建格式化的文档,无需复杂的排版工具。</p>
|
||||
</div>
|
||||
<div class="p-6 bg-gray-100 rounded-lg">
|
||||
<h4 class="text-lg font-bold mb-2">⚡ 高效输出</h4>
|
||||
<p>专注于内容创作,让工具自动处理排版,提高写作效率。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return function (...args: Parameters<T>) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
\`\`\`
|
||||
### 2.4 高亮提示框
|
||||
|
||||
### 3.3 构建优化
|
||||
<div class="p-6 bg-blue-50 border-l-4 border-blue-500 rounded-lg my-8">
|
||||
<h4 class="text-lg font-bold text-blue-700 mb-2">💡 小贴士</h4>
|
||||
<p class="text-blue-600">在写作时,可以先列出文章大纲,再逐步充实内容。这样可以保证文章结构清晰,内容完整。</p>
|
||||
</div>
|
||||
|
||||
![Build Optimization](https://images.unsplash.com/photo-1551033406-611cf9a28f67?w=1200&h=600)
|
||||
<div class="p-6 bg-yellow-50 border-l-4 border-yellow-500 rounded-lg my-8">
|
||||
<h4 class="text-lg font-bold text-yellow-700 mb-2">⚠️ 注意事项</h4>
|
||||
<p class="text-yellow-600">写作时要注意文章的受众,使用他们能理解的语言和例子。</p>
|
||||
</div>
|
||||
|
||||
## 4. 测试略
|
||||
### 2.5 时间线
|
||||
|
||||
### 4.1 单元测试
|
||||
<div class="relative pl-8 my-8 border-l-2 border-gray-200">
|
||||
<div class="mb-8 relative">
|
||||
<div class="absolute -left-10 w-4 h-4 bg-blue-500 rounded-full"></div>
|
||||
<div class="font-bold mb-2">1. 确定主题</div>
|
||||
<p>根据目标受众和写作目的,确定文章主题。</p>
|
||||
</div>
|
||||
|
||||
使用 Jest 进行单元测试:
|
||||
<div class="mb-8 relative">
|
||||
<div class="absolute -left-10 w-4 h-4 bg-blue-500 rounded-full"></div>
|
||||
<div class="font-bold mb-2">2. 收集资料</div>
|
||||
<p>广泛搜集相关资料,为写作做充分准备。</p>
|
||||
</div>
|
||||
|
||||
\`typescript
|
||||
describe('Utils', () => {
|
||||
test('debounce should work correctly', (done) => {
|
||||
let counter = 0;
|
||||
const increment = () => counter++;
|
||||
const debouncedIncrement = debounce(increment, 100);
|
||||
<div class="relative">
|
||||
<div class="absolute -left-10 w-4 h-4 bg-blue-500 rounded-full"></div>
|
||||
<div class="font-bold mb-2">3. 开始写作</div>
|
||||
<p>按照大纲逐步展开写作。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
debouncedIncrement();
|
||||
debouncedIncrement();
|
||||
debouncedIncrement();
|
||||
### 2.6 引用样式
|
||||
|
||||
expect(counter).toBe(0);
|
||||
> 📌 **最佳实践**
|
||||
>
|
||||
> 好的文章需要有清晰的结构和流畅的表达。以下是一些建议:
|
||||
>
|
||||
> 1. 开门见山,直入主题
|
||||
> 2. 层次分明,逻辑清晰
|
||||
> 3. 语言简洁,表达准确
|
||||
>
|
||||
> *— 写作指南*
|
||||
|
||||
setTimeout(() => {
|
||||
expect(counter).toBe(1);
|
||||
done();
|
||||
}, 150);
|
||||
});
|
||||
});
|
||||
\`\`\`
|
||||
## 3. 特殊语法
|
||||
|
||||
### 4.2 集成测试
|
||||
### 3.1 数学公式
|
||||
|
||||
使用 Cypress 进行端到端测试。
|
||||
行内公式:$E = mc^2$
|
||||
|
||||
![Testing](https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=1200&h=600)
|
||||
块级公式:
|
||||
|
||||
## 5. 部署与监控
|
||||
$$
|
||||
\\frac{n!}{k!(n-k)!} = \\binom{n}{k}
|
||||
$$
|
||||
|
||||
### 5.1 CI/CD 配置
|
||||
### 3.2 脚注
|
||||
|
||||
\`\`\`yaml
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
这里有一个脚注[^1]。
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install
|
||||
run: pnpm install
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- name: Deploy
|
||||
run: pnpm deploy
|
||||
\`\`\`
|
||||
[^1]: 这是脚注的内容。
|
||||
|
||||
### 5.2 监控系统
|
||||
### 3.3 表情符号
|
||||
|
||||
#### 5.2.1 性能监控
|
||||
:smile: :heart: :thumbsup: :star: :rocket:
|
||||
|
||||
关键指标监控:
|
||||
## 4. 总结
|
||||
|
||||
- 页面加载时间
|
||||
- 首次内容绘制
|
||||
- 首次大内容绘制
|
||||
- 首次输入延迟
|
||||
本文展示了 Markdown 从基础到高级的各种用法:
|
||||
|
||||
#### 5.2.2 错误监控
|
||||
1. 基础语法:文本格式化、列表、代码、表格等
|
||||
2. 高级排版:图文混排、折叠面板、卡片布局等
|
||||
3. 特殊语法:数学公式、脚注、表情符号等
|
||||
|
||||
错误示例:
|
||||
|
||||
\`\`\`typescript
|
||||
interface ErrorReport {
|
||||
type: 'error' | 'warning';
|
||||
message: string;
|
||||
stack?: string;
|
||||
timestamp: number;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
function reportError(error: Error): void {
|
||||
const report: ErrorReport = {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: Date.now(),
|
||||
userAgent: navigator.userAgent
|
||||
};
|
||||
|
||||
// 发送错误报告
|
||||
sendErrorReport(report);
|
||||
}
|
||||
\`\`
|
||||
|
||||
## 6. 安全最佳实践
|
||||
|
||||
### 6.1 XSS 防护
|
||||
|
||||
\`\`\`typescript
|
||||
// 安全的 HTML 转义函数
|
||||
function escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 6.2 CSRF 防护
|
||||
|
||||
![Security](https://images.unsplash.com/photo-1555949963-aa79dcee981c?w=1200&h=600)
|
||||
|
||||
## 总结
|
||||
|
||||
现代前端开发是一个复杂的系统工程,需要我们在以下方面不断精进:
|
||||
|
||||
1. 工程化能力
|
||||
2. 性能优化
|
||||
3. 测试覆盖
|
||||
4. 全防护
|
||||
5. 部署监控
|
||||
|
||||
> 持续学习实践是提高端开发水平的关键。
|
||||
|
||||
相关资源:
|
||||
- [MDN Web Docs](https://developer.mozilla.org/)
|
||||
- [Web.dev](https://web.dev/)
|
||||
- [GitHub](https://github.com/)
|
||||
> 💡 **提示**:部分高级排版功能可能需要特定的 Markdown 编辑器或渲染器支持。使用前请确认你的工具是否支持这些特性。
|
||||
`,
|
||||
authorName: "张三",
|
||||
authorName: "Markdown 专家",
|
||||
publishedAt: new Date("2024-03-15"),
|
||||
coverImage: "",
|
||||
metaKeywords: "前端开发,工程,效率",
|
||||
metaDescription: "探讨如何构建高效的前端开发高开发效率",
|
||||
coverImage: "https://images.unsplash.com/photo-1499951360447-b19be8fe80f5?w=1200&h=600",
|
||||
metaKeywords: "Markdown,基础语法,高级排版,布局设计",
|
||||
metaDescription: "从基础语法到高级排版,全面了解 Markdown 的各种用法和技巧。",
|
||||
status: "published",
|
||||
isEditor: true,
|
||||
createdAt: new Date("2024-03-15"),
|
||||
updatedAt: new Date("2024-03-15"),
|
||||
categories: [{ name: "前端开发" }],
|
||||
tags: [{ name: "工程化" }, { name: "效率提升" }, { name: "发工具" }],
|
||||
categories: [{ name: "教程" }],
|
||||
tags: [{ name: "Markdown" }, { name: "排版" }, { name: "写作" }],
|
||||
};
|
||||
|
||||
|
||||
// 添 meta 函数
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@ -325,23 +260,36 @@ interface CopyButtonProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// 加 CopyButton 组件
|
||||
// 修改 CopyButton 组件
|
||||
const CopyButton: React.FC<CopyButtonProps> = ({ code }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
toast.success("复制成功", "代码已复制到剪贴板");
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
toast.error("复制失败", "请检查浏览器权限设置");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
className="h-7 px-2 text-xs hover:bg-[--gray-4]"
|
||||
className="h-7 px-2 text-xs
|
||||
transition-all duration-300 ease-in-out
|
||||
[@media(hover:hover)]:hover:bg-[--gray-4]
|
||||
active:bg-[--gray-4] active:transition-none"
|
||||
>
|
||||
{copied ? "已复制" : "复制"}
|
||||
<span className="transition-opacity duration-300">
|
||||
{copied ? "已复制" : "复制"}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -379,12 +327,16 @@ export default new Template({}, ({ http, args }) => {
|
||||
const [activeId, setActiveId] = useState<string>("");
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [showToc, setShowToc] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(true);
|
||||
const [headingIdsArrays, setHeadingIdsArrays] = useState<{[key: string]: string[]}>({});
|
||||
const headingIds = useRef<string[]>([]); // 保持原有的 ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isClickScrolling = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@ -437,8 +389,6 @@ export default new Template({}, ({ http, args }) => {
|
||||
if (tocArray.length > 0) {
|
||||
setActiveId(tocArray[0].id);
|
||||
}
|
||||
|
||||
setIsMounted(true);
|
||||
}, [mockPost.content, mockPost.id]);
|
||||
|
||||
const components = useMemo(() => ({
|
||||
@ -509,21 +459,20 @@ export default new Template({}, ({ http, args }) => {
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<div className="my-4 sm:my-6 mx-0 sm:mx-0">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex justify-between items-center h-9 sm:h-10 px-6
|
||||
border-x border-t border-[--gray-6]
|
||||
bg-white dark:bg-[--gray-1]
|
||||
rounded-t-none sm:rounded-t-lg"
|
||||
<div className="my-4 sm:my-6">
|
||||
<div className="flex justify-between items-center h-9 sm:h-10 px-4 sm:px-6
|
||||
border-t border-x border-[--gray-6]
|
||||
bg-[--gray-3] dark:bg-[--gray-3]
|
||||
rounded-t-lg
|
||||
mx-0"
|
||||
>
|
||||
<div className="text-sm text-[--gray-11] dark:text-[--gray-12] font-medium">{lang || "text"}</div>
|
||||
<div className="text-sm text-[--gray-12] dark:text-[--gray-12] font-medium">{lang || "text"}</div>
|
||||
<CopyButton code={String(children)} />
|
||||
</div>
|
||||
|
||||
{/* 代码内容区域 */}
|
||||
<div className="overflow-x-auto border-x border-b border-[--gray-6] rounded-b-none sm:rounded-b-lg">
|
||||
<div className="min-w-[640px]">
|
||||
<div className="p-6 bg-[--gray-2] dark:bg-[--gray-3]">
|
||||
<div className="border border-[--gray-6] rounded-b-lg bg-white dark:bg-[--gray-1] mx-0">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="p-4 sm:p-6">
|
||||
<SyntaxHighlighter
|
||||
language={lang || "text"}
|
||||
style={{
|
||||
@ -563,9 +512,9 @@ export default new Template({}, ({ http, args }) => {
|
||||
// 修改表格相关组件的响应式设计
|
||||
table: ({ children, ...props }: ComponentPropsWithoutRef<'table'>) => (
|
||||
<div className="w-full my-4 sm:my-6 -mx-4 sm:mx-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="scroll-container overflow-x-auto">
|
||||
<div className="min-w-[640px] sm:min-w-0">
|
||||
<div className="border-x border-t sm:border-t border-[--gray-6] rounded-t-none sm:rounded-t-lg bg-white dark:bg-[--gray-1]">
|
||||
<div className="border border-[--gray-6] rounded-lg bg-white dark:bg-[--gray-1]">
|
||||
<table className="w-full border-collapse text-xs sm:text-sm" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
@ -578,7 +527,10 @@ export default new Template({}, ({ http, args }) => {
|
||||
th: ({ children, ...props }: ComponentPropsWithoutRef<'th'>) => (
|
||||
<th
|
||||
className="px-4 sm:px-4 md:px-6 py-2 sm:py-3 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider
|
||||
text-[--gray-12] break-words hyphens-auto"
|
||||
text-[--gray-12] break-words hyphens-auto
|
||||
bg-[--gray-3] dark:bg-[--gray-3]
|
||||
first:rounded-tl-lg last:rounded-tr-lg
|
||||
border-b border-[--gray-6]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@ -602,39 +554,60 @@ export default new Template({}, ({ http, args }) => {
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (isClickScrolling.current) return;
|
||||
if (!isMounted) return;
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id);
|
||||
}
|
||||
});
|
||||
const container = document.querySelector("#main-content");
|
||||
const contentBox = document.querySelector(".prose");
|
||||
|
||||
if (!container || !contentBox) return;
|
||||
|
||||
// 找出所有进入可视区域的标题
|
||||
const intersectingEntries = entries.filter(entry => entry.isIntersecting);
|
||||
|
||||
if (intersectingEntries.length > 0) {
|
||||
// 获取所有可见标题的位置信息
|
||||
const visibleHeadings = intersectingEntries.map(entry => ({
|
||||
id: entry.target.id,
|
||||
top: entry.boundingClientRect.top
|
||||
}));
|
||||
|
||||
// 选择靠近视口顶部的标题
|
||||
const closestHeading = visibleHeadings.reduce((prev, current) => {
|
||||
return Math.abs(current.top) < Math.abs(prev.top) ? current : prev;
|
||||
});
|
||||
|
||||
setActiveId(closestHeading.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: containerRef.current,
|
||||
rootMargin: '-80px 0px -80% 0px',
|
||||
threshold: 0.5
|
||||
root: document.querySelector("#main-content"),
|
||||
rootMargin: '-20px 0px -80% 0px',
|
||||
threshold: [0, 1]
|
||||
}
|
||||
);
|
||||
|
||||
tocItems.forEach((item) => {
|
||||
const element = document.getElementById(item.id);
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (isMounted) {
|
||||
tocItems.forEach((item) => {
|
||||
const element = document.getElementById(item.id);
|
||||
if (element) {
|
||||
observer.unobserve(element);
|
||||
observer.observe(element);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [tocItems]);
|
||||
}
|
||||
|
||||
// 修改点击<E782B9><E587BB>理函数
|
||||
return () => {
|
||||
if (isMounted) {
|
||||
tocItems.forEach((item) => {
|
||||
const element = document.getElementById(item.id);
|
||||
if (element) {
|
||||
observer.unobserve(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [tocItems, isMounted]);
|
||||
|
||||
// 修改点击处理函数
|
||||
const handleTocClick = useCallback((e: React.MouseEvent, itemId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.getElementById(itemId);
|
||||
@ -644,6 +617,7 @@ export default new Template({}, ({ http, args }) => {
|
||||
if (element && container && contentBox) {
|
||||
isClickScrolling.current = true;
|
||||
|
||||
// 计算元素相对于内容容器的偏移量
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const contentBoxRect = contentBox.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
@ -662,8 +636,6 @@ export default new Template({}, ({ http, args }) => {
|
||||
behavior: "smooth",
|
||||
});
|
||||
|
||||
setActiveId(itemId);
|
||||
|
||||
// 滚动完成后重置标记
|
||||
const resetTimeout = setTimeout(() => {
|
||||
isClickScrolling.current = false;
|
||||
@ -673,17 +645,19 @@ export default new Template({}, ({ http, args }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 修改<EFBFBD><EFBFBD>动端目录的渲染逻辑
|
||||
const mobileMenu = isMounted && (
|
||||
// 修改移动端目录的渲染逻辑
|
||||
const mobileMenu = (
|
||||
<>
|
||||
<Button
|
||||
className="lg:hidden fixed bottom-6 right-6 z-50 w-12 h-12 rounded-full shadow-lg bg-[--accent-9] text-white"
|
||||
onClick={() => setShowToc(true)}
|
||||
>
|
||||
<CodeIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
{isMounted && (
|
||||
<Button
|
||||
className="lg:hidden fixed bottom-6 right-6 z-50 w-12 h-12 rounded-full shadow-lg bg-[--accent-9] text-white"
|
||||
onClick={() => setShowToc(true)}
|
||||
>
|
||||
<CodeIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showToc && (
|
||||
{isMounted && showToc && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-50 bg-black/50 transition-opacity duration-300"
|
||||
onClick={() => setShowToc(false)}
|
||||
@ -710,7 +684,7 @@ export default new Template({}, ({ http, args }) => {
|
||||
<ScrollArea
|
||||
type="hover"
|
||||
scrollbars="vertical"
|
||||
className="h-[calc(100vh-64px)] p-4"
|
||||
className="scroll-container h-[calc(100vh-64px)] p-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{tocItems.map((item, index) => {
|
||||
@ -761,11 +735,11 @@ export default new Template({}, ({ http, args }) => {
|
||||
|
||||
return (
|
||||
<Container
|
||||
ref={containerRef} // 添加ref到最外层容器
|
||||
ref={containerRef}
|
||||
size={{initial: "2", sm: "3", md: "4"}}
|
||||
className="px-4 sm:px-6 md:px-8"
|
||||
>
|
||||
{mobileMenu}
|
||||
{isMounted && mobileMenu}
|
||||
|
||||
<Flex
|
||||
className="relative flex-col lg:flex-row"
|
||||
@ -773,7 +747,7 @@ export default new Template({}, ({ http, args }) => {
|
||||
>
|
||||
{/* 文章主体 */}
|
||||
<Box className="w-full lg:flex-1">
|
||||
<Box className="p-4 sm:p-6 md:p-8 bg-white dark:bg-[--gray-1] rounded-lg shadow-sm">
|
||||
<Box className="p-4 sm:p-6 md:p-8">
|
||||
{/* 头部 */}
|
||||
<Box className="mb-4 sm:mb-8">
|
||||
<Heading
|
||||
@ -864,7 +838,7 @@ export default new Template({}, ({ http, args }) => {
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 修改片样式 */}
|
||||
{/* 面图片 */}
|
||||
{mockPost.coverImage && (
|
||||
<Box className="mb-16 rounded-xl overflow-hidden aspect-[2/1] shadow-lg">
|
||||
<img
|
||||
@ -909,7 +883,7 @@ export default new Template({}, ({ http, args }) => {
|
||||
<ScrollArea
|
||||
type="hover"
|
||||
scrollbars="vertical"
|
||||
className="max-h-[calc(100vh-180px)]"
|
||||
className="scroll-container max-h-[calc(100vh-180px)]"
|
||||
style={{
|
||||
["--scrollbar-size" as string]: "6px",
|
||||
}}
|
||||
|
@ -1,8 +1,81 @@
|
||||
/* 导航链接样式 */
|
||||
/* 基础变量 */
|
||||
:root {
|
||||
/* 明亮模式的基础颜色 */
|
||||
--text-primary: var(--gray-12);
|
||||
--text-secondary: var(--gray-11);
|
||||
--text-tertiary: var(--gray-10);
|
||||
|
||||
/* 共用的尺寸 */
|
||||
--scrollbar-size: 8px;
|
||||
--border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 暗色主题变量 */
|
||||
:root[class~="dark"] {
|
||||
/* Radix UI 暗色主题变量覆盖 */
|
||||
--color-panel-solid: rgb(2, 6, 16);
|
||||
--color-surface: rgb(2, 6, 16);
|
||||
--color-background: rgb(2, 6, 16);
|
||||
|
||||
/* 覆盖灰度色板 */
|
||||
--slate-1: rgb(2, 6, 16);
|
||||
--slate-2: rgb(2, 6, 16);
|
||||
--slate-3: rgb(2, 6, 16);
|
||||
|
||||
/* 覆盖 Radix 的颜色变量 */
|
||||
--gray-1: rgb(2, 6, 16);
|
||||
--gray-2: rgb(2, 6, 16);
|
||||
--gray-3: rgb(2, 6, 16);
|
||||
--gray-4: rgb(4, 10, 24);
|
||||
--gray-5: rgb(5, 12, 28);
|
||||
--gray-6: rgb(6, 14, 32);
|
||||
|
||||
/* 文本颜色 */
|
||||
--gray-12: rgb(226, 232, 240);
|
||||
--gray-11: rgb(203, 213, 225);
|
||||
--gray-10: rgb(148, 163, 184);
|
||||
|
||||
/* 透明度变量 */
|
||||
--gray-a1: rgba(226, 232, 240, 0.05);
|
||||
--gray-a2: rgba(226, 232, 240, 0.08);
|
||||
--gray-a3: rgba(226, 232, 240, 0.1);
|
||||
--gray-a4: rgba(226, 232, 240, 0.12);
|
||||
--gray-a5: rgba(226, 232, 240, 0.14);
|
||||
--gray-a6: rgba(226, 232, 240, 0.16);
|
||||
}
|
||||
|
||||
/* 暗色主题背景 */
|
||||
body:has(:root[class~="dark"]) {
|
||||
background: rgb(2, 6, 16);
|
||||
}
|
||||
|
||||
/* 通用卡片样式 */
|
||||
.rt-Card,
|
||||
[class*="rt-Card"] {
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid var(--gray-a3) !important;
|
||||
/* 覆盖 Radix UI 的默认边框样式 */
|
||||
--card-border-width: 0 !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* 暗色主题卡片样式 */
|
||||
html[class~="dark"] body .rt-Card,
|
||||
html[class~="dark"] body [class*="rt-Card"] {
|
||||
background-color: rgb(2, 6, 16);
|
||||
}
|
||||
|
||||
/* 添加卡片悬停样式 */
|
||||
.rt-Card:hover,
|
||||
[class*="rt-Card"]:hover {
|
||||
border-color: var(--accent-9) !important;
|
||||
}
|
||||
|
||||
/* 导航链接样式 - 更具体的选择器 */
|
||||
#nav a {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--gray-12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#nav a:hover {
|
||||
@ -25,18 +98,35 @@
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* Markdown 内容链接样式 */
|
||||
.prose a {
|
||||
position: relative;
|
||||
color: var(--accent-9);
|
||||
text-decoration: none;
|
||||
padding-bottom: 1px;
|
||||
border-bottom: 1.5px solid var(--accent-8);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
color: var(--accent-11);
|
||||
border-bottom: 2px solid var(--accent-9);
|
||||
padding-bottom: 0.5px;
|
||||
}
|
||||
|
||||
/* 移除 Markdown 链接的导航样式 */
|
||||
.prose a::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 进度指示器动画 */
|
||||
@keyframes flow {
|
||||
0% {
|
||||
background-position: 0% center;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% center;
|
||||
}
|
||||
0% { background-position: 0% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
color: var(--gray-11);
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@ -55,34 +145,173 @@
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* 添加以下暗色主题的自定义变量 */
|
||||
.dark-theme-custom {
|
||||
--gray-1: hsl(220, 15%, 12%); /* 背景色,更柔和的深色 */
|
||||
--gray-2: hsl(220, 15%, 14%);
|
||||
--gray-3: hsl(220, 15%, 16%);
|
||||
--gray-12: hsl(220, 15%, 85%); /* 文本颜色,不要太白 */
|
||||
|
||||
/* 减少对比度,使文字更柔和 */
|
||||
--gray-11: hsl(220, 15%, 65%);
|
||||
|
||||
/* 边框颜色调整 */
|
||||
--gray-a5: hsla(220, 15%, 50%, 0.2);
|
||||
|
||||
/* 重要:确保背景和文本的对比度适中 */
|
||||
background-color: var(--gray-1);
|
||||
color: var(--gray-12);
|
||||
|
||||
/* 添加微弱的蓝光过滤 */
|
||||
filter: brightness(0.96) saturate(0.95);
|
||||
/* 滚动条基础样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: var(--scrollbar-size);
|
||||
height: var(--scrollbar-size);
|
||||
}
|
||||
|
||||
/* 优化暗色主题下的阴影效果 */
|
||||
.dark-theme-custom [class*='shadow'] {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(226, 232, 240, 0.5);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
/* 优化链接和交互元素的高亮颜色 */
|
||||
.dark-theme-custom a:hover,
|
||||
.dark-theme-custom button:hover {
|
||||
--accent-9: hsl(226, 70%, 65%); /* 更柔和的强调色 */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.5);
|
||||
border-radius: var(--border-radius);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.7);
|
||||
}
|
||||
|
||||
/* 暗色主题滚动条 */
|
||||
html[class~="dark"] ::-webkit-scrollbar-track {
|
||||
background: rgba(8, 27, 57, 0.6);
|
||||
}
|
||||
|
||||
html[class~="dark"] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
html[class~="dark"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
|
||||
/* 阴影效果 */
|
||||
[class*="shadow"] {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
html[class~="dark"] body [class*="shadow"] {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@keyframes in {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes out {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-out {
|
||||
animation: out 0.2s ease-in;
|
||||
}
|
||||
|
||||
/* 添加缩放动画 */
|
||||
@keyframes zoomIn {
|
||||
from { transform: scale(0.95); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes zoomOut {
|
||||
from { transform: scale(1); }
|
||||
to { transform: scale(0.95); }
|
||||
}
|
||||
|
||||
.zoom-in-95 {
|
||||
animation: zoomIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.zoom-out-95 {
|
||||
animation: zoomOut 0.2s ease-in;
|
||||
}
|
||||
|
||||
/* 通用交互效果 */
|
||||
.hover-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-card:hover {
|
||||
transform: translateY(-2px);
|
||||
background-color: var(--gray-4);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.hover-border {
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hover-border:hover {
|
||||
border-color: var(--accent-9);
|
||||
}
|
||||
|
||||
.hover-text {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.hover-text:hover {
|
||||
color: var(--accent-9);
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.hover-bg {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hover-bg:hover {
|
||||
background-color: var(--gray-3);
|
||||
}
|
||||
|
||||
/* 横向滚动条容器 */
|
||||
.scroll-container {
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
scrollbar-color: var(--gray-a6) transparent; /* Firefox */
|
||||
-webkit-overflow-scrolling: touch; /* iOS 滚动优化 */
|
||||
}
|
||||
|
||||
/* Webkit 滚动条样式 */
|
||||
.scroll-container::-webkit-scrollbar {
|
||||
height: 6px; /* 横向滚动条高度 */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
margin: 0 4px; /* 轨道边距 */
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar-thumb {
|
||||
background: var(--gray-a6);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--gray-a8);
|
||||
}
|
||||
|
||||
/* 暗色主题滚动条 */
|
||||
html[class~="dark"] .scroll-container::-webkit-scrollbar-thumb {
|
||||
background: var(--gray-a4);
|
||||
}
|
||||
|
||||
html[class~="dark"] .scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--gray-a5);
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保持可滚动 */
|
||||
.scroll-hidden {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scroll-hidden::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
|
@ -89,15 +89,12 @@ export default defineConfig(async ({ mode }) => {
|
||||
envPrefix: "VITE_",
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
three: ['three'],
|
||||
gsap: ['gsap']
|
||||
}
|
||||
}
|
||||
// 移除 manualChunks 配置
|
||||
},
|
||||
// 优化大型依赖的处理
|
||||
chunkSizeWarningLimit: 1000
|
||||
chunkSizeWarningLimit: 1500
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ['three', '@react-three/fiber', '@react-three/drei', 'gsap']
|
||||
}
|
||||
};
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user