前端:主题提取公共样式,新增多个管理页面组件,优化背景动画,改进样式和交互效果,更新文章内容和排版,主题模新增管理页面

This commit is contained in:
lsy 2024-12-09 02:25:32 +08:00
parent 66d47f6cfa
commit 14f50467f0
20 changed files with 3046 additions and 627 deletions

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

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

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

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

View File

@ -9,9 +9,11 @@ import {
DashboardIcon,
GearIcon,
FileTextIcon,
ImageIcon,
ReaderIcon,
LayersIcon,
FileIcon,
ColorWheelIcon,
HomeIcon,
} from "@radix-ui/react-icons";
import { Theme } from "@radix-ui/themes";
import { useState, useEffect } from "react";
@ -30,11 +32,6 @@ const menuItems = [
label: "文章管理",
path: "/dashboard/posts",
},
{
icon: <ImageIcon className="w-4 h-4" />,
label: "媒体管理",
path: "/dashboard/media",
},
{
icon: <ReaderIcon className="w-4 h-4" />,
label: "评论管理",
@ -45,11 +42,31 @@ const menuItems = [
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 }) => {
@ -187,46 +204,36 @@ export default new Layout(({ children }) => {
{/* 右侧用户菜单 */}
<Flex align="center" gap="4">
<Box className="flex items-center border-r border-[--gray-6] pr-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>
<DropdownMenuPrimitive.Root>
<DropdownMenuPrimitive.Trigger asChild>
{/* 返回主页按钮 */}
<Button
variant="ghost"
size="3"
className="gap-2 text-base"
onClick={() => {
window.location.href = '/';
}}
>
<PersonIcon className="w-5 h-5" />
<span className="hidden sm:inline"></span>
<HomeIcon className="w-5 h-5" />
<span className="hidden sm:inline"></span>
</Button>
</DropdownMenuPrimitive.Trigger>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
align="end"
sideOffset={5}
className="min-w-[180px] p-1 rounded-md bg-[--gray-1] border border-[--gray-6] shadow-lg"
>
<DropdownMenuPrimitive.Item
className="flex items-center gap-2 px-3 py-2 text-[--gray-12] hover:bg-[--gray-3] rounded outline-none cursor-pointer text-base"
>
<GearIcon className="w-5 h-5" />
</DropdownMenuPrimitive.Item>
<DropdownMenuPrimitive.Separator className="h-px my-1 bg-[--gray-6]" />
<DropdownMenuPrimitive.Item
className="flex items-center gap-2 px-3 py-2 text-[--gray-12] hover:bg-[--gray-3] rounded outline-none cursor-pointer text-base"
{/* 退出登录按钮 */}
<Button
variant="ghost"
size="3"
className="gap-2 text-base"
onClick={() => {
// 这里添加退出登录的逻辑
console.log('退出登录');
}}
>
<ExitIcon className="w-5 h-5" />
退
</DropdownMenuPrimitive.Item>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
<span className="hidden sm:inline">退</span>
</Button>
</Flex>
</Flex>
</Box>

View File

@ -2,10 +2,11 @@ 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 } from "react";
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);
@ -14,6 +15,8 @@ export default new Template({}, ({ http, args }) => {
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { mode } = useThemeMode();
const [hasBackgroundError, setHasBackgroundError] = useState(false);
const notification = useNotification();
useEffect(() => {
setIsVisible(true);
@ -58,20 +61,36 @@ export default new Template({}, ({ http, args }) => {
// 这里添加登录逻辑
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 (
<>
<AnimatedBackground />
<div className="relative min-h-screen">
{backgroundComponent}
<Box
className="fixed top-4 right-4 z-10 w-10 h-10 flex items-center justify-center [&_button]:w-10 [&_button]:h-10 [&_svg]:w-6 [&_svg]:h-6"
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)'
@ -82,7 +101,7 @@ export default new Template({}, ({ http, args }) => {
<Container
ref={containerRef}
className={`h-screen w-full flex items-center justify-center transition-all duration-300 ${
className={`relative z-10 h-screen w-full flex items-center justify-center transition-all duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
@ -106,36 +125,32 @@ export default new Template({}, ({ http, args }) => {
<form onSubmit={handleLogin}>
<Flex direction="column" gap="4">
{/* 用户名输入框 */}
<Box className="form-element relative">
<PersonIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: 'var(--gray-11)' }} />
<Box className="form-element input-box relative">
<input
className="login-input pl-10"
placeholder="请输入用户名"
className="login-input"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<label></label>
</Box>
{/* 密码输入框 */}
<Box className="form-element relative">
<LockClosedIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
style={{ color: 'var(--gray-11)' }} />
<Box className="form-element input-box relative">
<input
className="login-input pl-10"
placeholder="请输入密码"
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"
className="login-button w-full h-10 transition-colors duration-300 hover:bg-[--hover-bg]"
style={{
backgroundColor: 'var(--accent-9)',
color: 'white',
@ -152,7 +167,7 @@ export default new Template({}, ({ http, args }) => {
<Flex justify="center" className="form-element">
<Text
size="2"
className="cursor-pointer transition-colors duration-300"
className="cursor-pointer transition-colors duration-300 hover:text-[--hover-color]"
style={{
color: 'var(--gray-11)',
'--hover-color': 'var(--accent-9)'
@ -166,6 +181,6 @@ export default new Template({}, ({ http, args }) => {
</Box>
</Box>
</Container>
</>
</div>
);
});

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

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

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

View File

@ -1,53 +1,63 @@
.login-input {
width: 100%;
height: 40px;
padding: 8px 12px;
border: 1px solid var(--gray-6);
border-radius: 6px;
background: rgba(255, 255, 255, 0.8);
padding: 10px 0;
font-size: 16px;
color: var(--gray-12);
margin-bottom: 30px;
border: none;
border-bottom: 1px solid var(--gray-8);
outline: none;
transition: all 0.2s;
backdrop-filter: blur(8px);
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 {
background: rgba(0, 0, 0, 0.2);
border-color: var(--gray-7);
backdrop-filter: blur(8px);
color: var(--gray-11);
border-bottom-color: var(--gray-7);
caret-color: var(--accent-8);
}
.login-input:hover {
border-color: var(--gray-7);
background: rgba(255, 255, 255, 0.9);
}
:root[class~="dark"] .login-input:hover {
background: rgba(0, 0, 0, 0.3);
border-color: var(--gray-8);
}
.login-input:focus {
border-color: var(--accent-8);
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 0 0 1px var(--accent-8);
}
:root[class~="dark"] .login-input:focus {
background: rgba(0, 0, 0, 0.4);
border-color: var(--accent-8);
box-shadow: 0 0 0 1px var(--accent-8);
}
.login-input::placeholder {
color: var(--gray-9);
}
:root[class~="dark"] .login-input::placeholder {
: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;

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

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

View File

@ -7,11 +7,20 @@ 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;
// 创建布局渲染器的工厂函数
@ -26,11 +35,11 @@ const createLayoutRenderer = (layoutComponent: any) => {
// 使用工厂函数创建不同的布局渲染器
const renderLayout = createLayoutRenderer(layout);
const renderAdminLayout = createLayoutRenderer(adminLayout);
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) => {
@ -38,55 +47,50 @@ const Routes = memo(() => {
}, []);
// 添加管理后台内容渲染函数
const renderAdminContent = useCallback((Component: any) => {
return renderAdminLayout(Component.render(args));
const renderDashboardContent = useCallback((Component: any) => {
return renderDashboardLayout(Component.render(args));
}, []);
// 根据路径返回对应组件
if (path === "error") {
// 前台路由
switch (mainPath) {
case "error":
return renderContent(ErrorPage);
}
if (path === "about") {
case "about":
return renderContent(about);
}
if (path === "post") {
case "post":
return renderContent(post);
}
if (path === "login") {
case "login":
return login.render(args);
}
// 添加管理后台路由判断
if (path === "admin") {
// 这里可以根据实际需要添加不同的管理页面组件
const subPath = location.pathname.split("/")[2];
// 如果没有子路径,显示仪表盘
case "dashboard":
// 管理后台路由
if (!subPath) {
return renderAdminLayout(<div></div>);
return renderDashboardContent(dashboard);
}
// 根据子路径返回对应的管理页面
switch (subPath) {
case "posts":
return renderAdminLayout(<div></div>);
case "media":
return renderAdminLayout(<div></div>);
return renderDashboardContent(posts);
case "comments":
return renderAdminLayout(<div></div>);
return renderDashboardContent(comments);
case "categories":
return renderAdminLayout(<div></div>);
return renderDashboardContent(categories);
case "files":
return renderDashboardContent(files);
case "settings":
return renderAdminLayout(<div></div>);
return renderDashboardContent(settings);
case "themes":
return renderDashboardContent(themes);
case "users":
return renderDashboardContent(users);
case "plugins":
return renderDashboardContent(plugins);
default:
return renderAdminLayout(<div>404 </div>);
return renderDashboardContent(<div>404 </div>);
}
}
default:
return renderContent(article);
}
});
export default Routes;

View File

@ -1,27 +1,41 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, memo } from 'react';
import { useThemeMode } from 'hooks/ThemeMode';
export const AnimatedBackground = () => {
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) return;
if (!canvas) {
onError?.();
return;
}
const ctx = canvas.getContext('2d')!;
if (!ctx) return;
try {
const ctx = canvas.getContext('2d', {
alpha: true,
desynchronized: true
});
const canvasElement = canvas!;
if (!ctx) {
console.error('无法获取 canvas context');
onError?.();
return;
}
// 生成随机HSL颜色
// 添加非空断言
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;
const lightness = mode === 'dark' ? 40 + Math.random() * 20 : 60 + Math.random() * 20;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
@ -32,40 +46,40 @@ export const AnimatedBackground = () => {
let dx = 0.2;
let dy = -0.2;
// 设置canvas尺寸为窗口大小
const resizeCanvas = () => {
// 保存调整前的相对位置
const relativeX = x / canvas.width;
const relativeY = y / canvas.height;
// 添加 drawBall 函数
function drawBall() {
context.beginPath();
context.arc(x, y, ballRadius, 0, Math.PI * 2);
context.fillStyle = ballColor;
context.fill();
context.closePath();
}
// 更新canvas尺寸
// 设置 canvas 尺寸
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// 根据新尺寸更新球的位置
x = canvas.width * relativeX;
y = canvas.height * relativeY;
// 性能优化:降低动画帧率
const fps = 30;
const interval = 1000 / fps;
let then = Date.now();
// 立即重绘
drawBall();
};
const draw = () => {
const now = Date.now();
const delta = now - then;
function drawBall() {
ctx.beginPath();
ctx.arc(x, y, ballRadius, 0, Math.PI * 2);
ctx.fillStyle = ballColor;
ctx.fill();
ctx.closePath();
}
if (delta > interval) {
// 更新时间戳
then = now - (delta % interval);
function draw() {
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
// 绘制逻辑...
context.clearRect(0, 0, canvas.width, canvas.height);
drawBall();
if (x + dx > canvasElement.width - ballRadius || x + dx < ballRadius) {
if (x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
dx = -dx;
}
if (y + dy > canvasElement.height - ballRadius || y + dy < ballRadius) {
if (y + dy > canvas.height - ballRadius || y + dy < ballRadius) {
dy = -dy;
}
@ -73,23 +87,39 @@ export const AnimatedBackground = () => {
y += dy;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
const interval = setInterval(draw, 10);
return () => {
clearInterval(interval);
window.removeEventListener('resize', resizeCanvas);
// 使用 requestAnimationFrame 代替 setInterval
animationFrameId = requestAnimationFrame(draw);
};
}, [mode]);
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">
<div className="fixed inset-0 -z-10 overflow-hidden">
<canvas
ref={canvasRef}
className="w-full h-full"
style={{ filter: 'blur(150px)' }}
className="w-full h-full opacity-50"
style={{
filter: 'blur(150px)',
position: 'absolute',
top: 0,
left: 0,
willChange: 'transform'
}}
/>
</div>
);
};
});

View File

@ -11,5 +11,6 @@ export interface ThemeConfig {
layout?: string;
configuration: Configuration;
error?: string;
manage?: string;
routes: Map<string, string>;
}

View File

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

View File

@ -62,7 +62,7 @@ const mockArticles: PostDisplay[] = [
content: "在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...",
authorName: "王五",
publishedAt: new Date("2024-03-13"),
coverImage: "https://mages.unsplash.com/photo-1592609931095-54a2168ae893?w=500&auto=format",
coverImage: "ssssxx",
metaKeywords: "",
metaDescription: "",
status: "published",
@ -156,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",
@ -244,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">
@ -279,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

View File

@ -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,138 +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={20}
className="min-w-[200px] rounded-md bg-[--gray-1] border border-[--gray-a5] shadow-lg
data-[state=open]:animate-in
data-[state=closed]:animate-out
data-[state=closed]:fade-out-0
data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95
data-[state=open]:zoom-in-95
data-[state=closed]:slide-in-from-top-2
data-[state=left]:slide-in-from-right-2
data-[state=right]:slide-in-from-left-2
data-[state=top]:slide-in-from-bottom-2
duration-200
max-h-[calc(100vh-6rem)]
overflow-y-auto
z-50
absolute
top-full
right-0"
>
<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>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
</Box>
{mobileMenu}
</Flex>
</Flex>
</Container>

View File

@ -20,287 +20,221 @@ 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
\`\`\`
### 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 [
@ -326,23 +260,36 @@ interface CopyButtonProps {
code: string;
}
// CopyButton 组件
// 修改 CopyButton 组件
const CopyButton: React.FC<CopyButtonProps> = ({ code }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
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"
>
<span className="transition-opacity duration-300">
{copied ? "已复制" : "复制"}
</span>
</Button>
);
};
@ -515,11 +462,11 @@ export default new Template({}, ({ http, args }) => {
<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-2] dark:bg-[--gray-2]
bg-[--gray-3] dark:bg-[--gray-3]
rounded-t-lg
mx-0"
>
<div className="text-sm text-[--gray-11] dark:text-[--gray-11] 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>
@ -565,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 border-b sm:border-t border-[--gray-6] rounded-none sm:rounded-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>
@ -580,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}
@ -621,7 +571,7 @@ export default new Template({}, ({ http, args }) => {
top: entry.boundingClientRect.top
}));
// 选择靠近视口顶部的标题
// 选择靠近视口顶部的标题
const closestHeading = visibleHeadings.reduce((prev, current) => {
return Math.abs(current.top) < Math.abs(prev.top) ? current : prev;
});
@ -734,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) => {
@ -888,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
@ -933,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",
}}

View File

@ -53,37 +53,25 @@ body:has(:root[class~="dark"]) {
.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);
border-color: rgba(148, 163, 184, 0.1);
}
/* 通用交互元素样式 */
.rt-Button,
.rt-DialogContent,
.rt-DropdownMenuContent,
[class*="rt-Button"],
[class*="rt-DialogContent"],
[class*="rt-DropdownMenuContent"] {
transition: all 0.2s ease;
/* 添加卡片悬停样式 */
.rt-Card:hover,
[class*="rt-Card"]:hover {
border-color: var(--accent-9) !important;
}
/* 暗色主题交互元素 */
html[class~="dark"] body .rt-Button,
html[class~="dark"] body .rt-DialogContent,
html[class~="dark"] body .rt-DropdownMenuContent,
html[class~="dark"] body [class*="rt-Button"],
html[class~="dark"] body [class*="rt-DialogContent"],
html[class~="dark"] body [class*="rt-DropdownMenuContent"] {
background-color: rgb(2, 6, 16);
border-color: rgba(148, 163, 184, 0.1);
}
/* 导航链接样式 */
/* 导航链接样式 - 更具体的选择器 */
#nav a {
position: relative;
transition: all 0.2s ease;
@ -110,6 +98,27 @@ html[class~="dark"] body [class*="rt-DropdownMenuContent"] {
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; }
@ -215,3 +224,94 @@ html[class~="dark"] body [class*="shadow"] {
.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 */
}