后端:更新CORS配置;前端,修复数据库构建有问题:构建模式和生产模式配置为一个端口,删除代理

This commit is contained in:
lsy 2024-12-12 23:27:36 +08:00
parent 195f8de576
commit f336271ad6
36 changed files with 1906 additions and 1582 deletions

View File

@ -23,4 +23,5 @@ rand = "0.8.5"
chrono = "0.4" chrono = "0.4"
regex = "1.11.1" regex = "1.11.1"
bcrypt = "0.16" bcrypt = "0.16"
hex = "0.4.3" hex = "0.4.3"
rocket_cors = "0.6.0"

View File

@ -5,10 +5,13 @@ mod storage;
use crate::common::config; use crate::common::config;
use common::error::{CustomErrorInto, CustomResult}; use common::error::{CustomErrorInto, CustomResult};
use rocket::http::Method;
use rocket::Shutdown; use rocket::Shutdown;
use rocket_cors::{AllowedHeaders, AllowedOrigins, Cors, CorsOptions};
use std::sync::Arc; use std::sync::Arc;
use storage::sql; use storage::sql;
use tokio::sync::Mutex; use tokio::sync::Mutex;
pub struct AppState { pub struct AppState {
db: Arc<Mutex<Option<sql::Database>>>, db: Arc<Mutex<Option<sql::Database>>>,
shutdown: Arc<Mutex<Option<Shutdown>>>, shutdown: Arc<Mutex<Option<Shutdown>>>,
@ -61,6 +64,25 @@ impl AppState {
} }
} }
fn cors() -> Cors {
CorsOptions {
allowed_origins: AllowedOrigins::all(),
allowed_methods: vec![Method::Get, Method::Post, Method::Options]
.into_iter()
.map(From::from)
.collect(),
allowed_headers: AllowedHeaders::all(),
allow_credentials: true,
expose_headers: Default::default(),
max_age: None,
send_wildcard: false,
fairing_route_base: "/".to_string(),
fairing_route_rank: 0,
}
.to_cors()
.expect("CORS配置错误")
}
#[rocket::main] #[rocket::main]
async fn main() -> CustomResult<()> { async fn main() -> CustomResult<()> {
let config = config::Config::read().unwrap_or_else(|e| { let config = config::Config::read().unwrap_or_else(|e| {
@ -75,7 +97,8 @@ async fn main() -> CustomResult<()> {
let mut rocket_builder = rocket::build() let mut rocket_builder = rocket::build()
.configure(rocket_config) .configure(rocket_config)
.manage(state.clone()); .manage(state.clone())
.attach(cors());
if !config.init.sql { if !config.init.sql {
rocket_builder = rocket_builder.mount("/", rocket::routes![api::setup::setup_sql]); rocket_builder = rocket_builder.mount("/", rocket::routes![api::setup::setup_sql]);

View File

@ -254,7 +254,7 @@ pub enum Operator {
Lte, Lte,
Like, Like,
In, In,
IsNull IsNull,
} }
impl Operator { impl Operator {
@ -268,7 +268,7 @@ impl Operator {
Operator::Lte => "<=", Operator::Lte => "<=",
Operator::Like => "LIKE", Operator::Like => "LIKE",
Operator::In => "IN", Operator::In => "IN",
Operator::IsNull => "IS NULL" Operator::IsNull => "IS NULL",
} }
} }
} }
@ -295,7 +295,7 @@ pub enum WhereClause {
And(Vec<WhereClause>), And(Vec<WhereClause>),
Or(Vec<WhereClause>), Or(Vec<WhereClause>),
Condition(Condition), Condition(Condition),
Not(Condition) Not(Condition),
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@ -541,7 +541,9 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.add_field(Field::new( .add_field(Field::new(
"status", "status",
FieldType::VarChar(20), FieldType::VarChar(20),
FieldConstraint::new().not_null().check(content_state_check.clone()), FieldConstraint::new()
.not_null()
.check(content_state_check.clone()),
ValidationLevel::Strict, ValidationLevel::Strict,
)?); )?);
@ -587,7 +589,9 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.add_field(Field::new( .add_field(Field::new(
"status", "status",
FieldType::VarChar(20), FieldType::VarChar(20),
FieldConstraint::new().not_null().check(content_state_check.clone()), FieldConstraint::new()
.not_null()
.check(content_state_check.clone()),
ValidationLevel::Strict, ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
@ -728,7 +732,9 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.add_field(Field::new( .add_field(Field::new(
"target_type", "target_type",
FieldType::VarChar(20), FieldType::VarChar(20),
FieldConstraint::new().not_null().check(target_type_check.clone()), FieldConstraint::new()
.not_null()
.check(target_type_check.clone()),
ValidationLevel::Strict, ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
@ -782,7 +788,9 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
.add_field(Field::new( .add_field(Field::new(
"target_type", "target_type",
FieldType::VarChar(20), FieldType::VarChar(20),
FieldConstraint::new().not_null().check(target_type_check.clone()), FieldConstraint::new()
.not_null()
.check(target_type_check.clone()),
ValidationLevel::Strict, ValidationLevel::Strict,
)?) )?)
.add_field(Field::new( .add_field(Field::new(
@ -843,10 +851,7 @@ pub fn generate_schema(db_type: DatabaseType, db_prefix: SafeValue) -> CustomRes
WhereClause::Condition(Condition::new( WhereClause::Condition(Condition::new(
"type".to_string(), "type".to_string(),
Operator::Eq, Operator::Eq,
Some(SafeValue::Text( Some(SafeValue::Text("'tag'".to_string(), ValidationLevel::Raw)),
"'tag'".to_string(),
ValidationLevel::Raw,
)),
)?), )?),
WhereClause::Condition(Condition::new( WhereClause::Condition(Condition::new(
"type".to_string(), "type".to_string(),

View File

@ -1,16 +1,16 @@
import { Template } from "interface/template"; import { Template } from "interface/template";
import { import {
Container, Container,
Heading, Heading,
Text, Text,
Box, Box,
Flex, Flex,
Table, Table,
Button, Button,
TextField, TextField,
ScrollArea, ScrollArea,
Dialog, Dialog,
IconButton IconButton,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { import {
PlusIcon, PlusIcon,
@ -28,39 +28,41 @@ const mockCategories: (Category & { id: number; count: number })[] = [
id: 1, id: 1,
name: "前端开发", name: "前端开发",
parentId: undefined, parentId: undefined,
count: 15 count: 15,
}, },
{ {
id: 2, id: 2,
name: "React", name: "React",
parentId: "1", parentId: "1",
count: 8 count: 8,
}, },
{ {
id: 3, id: 3,
name: "Vue", name: "Vue",
parentId: "1", parentId: "1",
count: 5 count: 5,
}, },
{ {
id: 4, id: 4,
name: "后端开发", name: "后端开发",
parentId: undefined, parentId: undefined,
count: 12 count: 12,
}, },
{ {
id: 5, id: 5,
name: "Node.js", name: "Node.js",
parentId: "4", parentId: "4",
count: 6 count: 6,
} },
]; ];
export default new Template({}, ({ http, args }) => { export default new Template({}, ({ http, args }) => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [newCategoryName, setNewCategoryName] = useState(""); const [newCategoryName, setNewCategoryName] = useState("");
const [selectedParentId, setSelectedParentId] = useState<string | undefined>(); const [selectedParentId, setSelectedParentId] = useState<
string | undefined
>();
return ( return (
<Box> <Box>
@ -74,7 +76,7 @@ export default new Template({}, ({ http, args }) => {
{mockCategories.length} {mockCategories.length}
</Text> </Text>
</Box> </Box>
<Button <Button
className="bg-[--accent-9]" className="bg-[--accent-9]"
onClick={() => setIsAddDialogOpen(true)} onClick={() => setIsAddDialogOpen(true)}
> >
@ -85,10 +87,12 @@ export default new Template({}, ({ http, args }) => {
{/* 搜索栏 */} {/* 搜索栏 */}
<Box className="w-full sm:w-64 mb-6"> <Box className="w-full sm:w-64 mb-6">
<TextField.Root <TextField.Root
placeholder="搜索分类..." placeholder="搜索分类..."
value={searchTerm} value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
> >
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
@ -125,10 +129,11 @@ export default new Template({}, ({ http, args }) => {
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Text> <Text>
{category.parentId {category.parentId
? mockCategories.find(c => c.id.toString() === category.parentId)?.name ? mockCategories.find(
: '-' (c) => c.id.toString() === category.parentId,
} )?.name
: "-"}
</Text> </Text>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
@ -164,7 +169,9 @@ export default new Template({}, ({ http, args }) => {
<TextField.Root <TextField.Root
placeholder="输入分类名称" placeholder="输入分类名称"
value={newCategoryName} value={newCategoryName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewCategoryName(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNewCategoryName(e.target.value)
}
/> />
</Box> </Box>
@ -179,13 +186,12 @@ export default new Template({}, ({ http, args }) => {
> >
<option value=""></option> <option value=""></option>
{mockCategories {mockCategories
.filter(c => !c.parentId) .filter((c) => !c.parentId)
.map(category => ( .map((category) => (
<option key={category.id} value={category.id}> <option key={category.id} value={category.id}>
{category.name} {category.name}
</option> </option>
)) ))}
}
</select> </select>
</Box> </Box>
</Flex> </Flex>
@ -197,13 +203,11 @@ export default new Template({}, ({ http, args }) => {
</Button> </Button>
</Dialog.Close> </Dialog.Close>
<Dialog.Close> <Dialog.Close>
<Button className="bg-[--accent-9]"> <Button className="bg-[--accent-9]"></Button>
</Button>
</Dialog.Close> </Dialog.Close>
</Flex> </Flex>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
</Box> </Box>
); );
}); });

View File

@ -1,10 +1,10 @@
import { Template } from "interface/template"; import { Template } from "interface/template";
import { import {
Container, Container,
Heading, Heading,
Text, Text,
Box, Box,
Flex, Flex,
Table, Table,
Button, Button,
TextField, TextField,
@ -12,7 +12,7 @@ import {
ScrollArea, ScrollArea,
DataList, DataList,
Avatar, Avatar,
Badge Badge,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
@ -32,7 +32,7 @@ const mockComments = [
postTitle: "构建现代化的前端开发工作流", postTitle: "构建现代化的前端开发工作流",
createdAt: new Date("2024-03-15"), createdAt: new Date("2024-03-15"),
status: "pending", // pending, approved, rejected status: "pending", // pending, approved, rejected
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1" avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=1",
}, },
{ {
id: 2, id: 2,
@ -41,7 +41,7 @@ const mockComments = [
postTitle: "React 18 新特性详解", postTitle: "React 18 新特性详解",
createdAt: new Date("2024-03-14"), createdAt: new Date("2024-03-14"),
status: "approved", status: "approved",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2" avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
}, },
// 可以添加更多模拟数据 // 可以添加更多模拟数据
]; ];
@ -51,24 +51,24 @@ export default new Template({}, ({ http, args }) => {
const [selectedStatus, setSelectedStatus] = useState<string>("all"); const [selectedStatus, setSelectedStatus] = useState<string>("all");
const getStatusStyle = (status: string) => { const getStatusStyle = (status: string) => {
switch(status) { switch (status) {
case 'approved': case "approved":
return 'bg-[--green-3] text-[--green-11]'; return "bg-[--green-3] text-[--green-11]";
case 'rejected': case "rejected":
return 'bg-[--red-3] text-[--red-11]'; return "bg-[--red-3] text-[--red-11]";
default: default:
return 'bg-[--yellow-3] text-[--yellow-11]'; return "bg-[--yellow-3] text-[--yellow-11]";
} }
}; };
const getStatusText = (status: string) => { const getStatusText = (status: string) => {
switch(status) { switch (status) {
case 'approved': case "approved":
return '已通过'; return "已通过";
case 'rejected': case "rejected":
return '已拒绝'; return "已拒绝";
default: default:
return '待审核'; return "待审核";
} }
}; };
@ -87,15 +87,14 @@ export default new Template({}, ({ http, args }) => {
</Flex> </Flex>
{/* 搜索和筛选栏 */} {/* 搜索和筛选栏 */}
<Flex <Flex gap="4" className="mb-6 flex-col sm:flex-row">
gap="4"
className="mb-6 flex-col sm:flex-row"
>
<Box className="w-full sm:w-64"> <Box className="w-full sm:w-64">
<TextField.Root <TextField.Root
placeholder="搜索评论..." placeholder="搜索评论..."
value={searchTerm} value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
> >
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
@ -106,20 +105,20 @@ export default new Template({}, ({ http, args }) => {
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<Button variant="surface"> <Button variant="surface">
: {selectedStatus === 'all' ? '全部' : selectedStatus} : {selectedStatus === "all" ? "全部" : selectedStatus}
</Button> </Button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Item onClick={() => setSelectedStatus('all')}> <DropdownMenu.Item onClick={() => setSelectedStatus("all")}>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => setSelectedStatus('pending')}> <DropdownMenu.Item onClick={() => setSelectedStatus("pending")}>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => setSelectedStatus('approved')}> <DropdownMenu.Item onClick={() => setSelectedStatus("approved")}>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => setSelectedStatus('rejected')}> <DropdownMenu.Item onClick={() => setSelectedStatus("rejected")}>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
@ -158,20 +157,22 @@ export default new Template({}, ({ http, args }) => {
</Flex> </Flex>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Text className="line-clamp-2"> <Text className="line-clamp-2">{comment.content}</Text>
{comment.content}
</Text>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Text className="line-clamp-1"> <Text className="line-clamp-1">{comment.postTitle}</Text>
{comment.postTitle}
</Text>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Flex gap="2"> <Flex gap="2">
{comment.status === 'approved' && <Badge color="green"></Badge>} {comment.status === "approved" && (
{comment.status === 'pending' && <Badge color="orange"></Badge>} <Badge color="green"></Badge>
{comment.status === 'rejected' && <Badge color="red"></Badge>} )}
{comment.status === "pending" && (
<Badge color="orange"></Badge>
)}
{comment.status === "rejected" && (
<Badge color="red"></Badge>
)}
</Flex> </Flex>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
@ -179,25 +180,21 @@ export default new Template({}, ({ http, args }) => {
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Flex gap="2"> <Flex gap="2">
<Button <Button
variant="ghost" variant="ghost"
size="1" size="1"
className="text-[--green-11] hover:text-[--green-12]" className="text-[--green-11] hover:text-[--green-12]"
> >
<CheckIcon className="w-4 h-4" /> <CheckIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="1" size="1"
className="text-[--red-11] hover:text-[--red-12]" className="text-[--red-11] hover:text-[--red-12]"
> >
<Cross2Icon className="w-4 h-4" /> <Cross2Icon className="w-4 h-4" />
</Button> </Button>
<Button <Button variant="ghost" size="1" color="red">
variant="ghost"
size="1"
color="red"
>
<TrashIcon className="w-4 h-4" /> <TrashIcon className="w-4 h-4" />
</Button> </Button>
</Flex> </Flex>
@ -211,7 +208,10 @@ export default new Template({}, ({ http, args }) => {
{/* 移动端列表视图 */} {/* 移动端列表视图 */}
<div className="block sm:hidden"> <div className="block sm:hidden">
{mockComments.map((comment) => ( {mockComments.map((comment) => (
<DataList.Root key={comment.id} className="p-4 border-b border-[--gray-6] last:border-b-0"> <DataList.Root
key={comment.id}
className="p-4 border-b border-[--gray-6] last:border-b-0"
>
<DataList.Item> <DataList.Item>
<DataList.Label minWidth="88px"></DataList.Label> <DataList.Label minWidth="88px"></DataList.Label>
<DataList.Value> <DataList.Value>
@ -245,9 +245,15 @@ export default new Template({}, ({ http, args }) => {
<DataList.Label minWidth="88px"></DataList.Label> <DataList.Label minWidth="88px"></DataList.Label>
<DataList.Value> <DataList.Value>
<Flex gap="2"> <Flex gap="2">
{comment.status === 'approved' && <Badge color="green"></Badge>} {comment.status === "approved" && (
{comment.status === 'pending' && <Badge color="orange"></Badge>} <Badge color="green"></Badge>
{comment.status === 'rejected' && <Badge color="red"></Badge>} )}
{comment.status === "pending" && (
<Badge color="orange"></Badge>
)}
{comment.status === "rejected" && (
<Badge color="red"></Badge>
)}
</Flex> </Flex>
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
@ -263,25 +269,21 @@ export default new Template({}, ({ http, args }) => {
<DataList.Label minWidth="88px"></DataList.Label> <DataList.Label minWidth="88px"></DataList.Label>
<DataList.Value> <DataList.Value>
<Flex gap="2"> <Flex gap="2">
<Button <Button
variant="ghost" variant="ghost"
size="1" size="1"
className="text-[--green-11] hover:text-[--green-12]" className="text-[--green-11] hover:text-[--green-12]"
> >
<CheckIcon className="w-4 h-4" /> <CheckIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="1" size="1"
className="text-[--red-11] hover:text-[--red-12]" className="text-[--red-11] hover:text-[--red-12]"
> >
<Cross2Icon className="w-4 h-4" /> <Cross2Icon className="w-4 h-4" />
</Button> </Button>
<Button <Button variant="ghost" size="1" color="red">
variant="ghost"
size="1"
color="red"
>
<TrashIcon className="w-4 h-4" /> <TrashIcon className="w-4 h-4" />
</Button> </Button>
</Flex> </Flex>
@ -294,4 +296,4 @@ export default new Template({}, ({ http, args }) => {
</Box> </Box>
</Box> </Box>
); );
}); });

View File

@ -1,17 +1,17 @@
import { Template } from "interface/template"; import { Template } from "interface/template";
import { import {
Container, Container,
Heading, Heading,
Text, Text,
Box, Box,
Flex, Flex,
Table, Table,
Button, Button,
TextField, TextField,
DropdownMenu, DropdownMenu,
ScrollArea, ScrollArea,
Dialog, Dialog,
DataList DataList,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { import {
PlusIcon, PlusIcon,
@ -22,7 +22,7 @@ import {
DotsHorizontalIcon, DotsHorizontalIcon,
FileTextIcon, FileTextIcon,
ImageIcon, ImageIcon,
VideoIcon VideoIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useState } from "react"; import { useState } from "react";
import type { Resource } from "interface/fields"; import type { Resource } from "interface/fields";
@ -38,7 +38,7 @@ const mockFiles: Resource[] = [
fileType: "application/pdf", fileType: "application/pdf",
category: "documents", category: "documents",
description: "前端开发规范文档", description: "前端开发规范文档",
createdAt: new Date("2024-03-15") createdAt: new Date("2024-03-15"),
}, },
{ {
id: 2, id: 2,
@ -49,19 +49,19 @@ const mockFiles: Resource[] = [
fileType: "image/png", fileType: "image/png",
category: "images", category: "images",
description: "项目整体架构示意图", description: "项目整体架构示意图",
createdAt: new Date("2024-03-14") createdAt: new Date("2024-03-14"),
}, },
{ {
id: 3, id: 3,
authorId: "1", authorId: "1",
name: "API文档.md", name: "API文档.md",
sizeBytes: 1024 * 256, // 256KB sizeBytes: 1024 * 256, // 256KB
storagePath: "/files/api-doc.md", storagePath: "/files/api-doc.md",
fileType: "text/markdown", fileType: "text/markdown",
category: "documents", category: "documents",
description: "API接口文档", description: "API接口文档",
createdAt: new Date("2024-03-13") createdAt: new Date("2024-03-13"),
} },
]; ];
export default new Template({}, ({ http, args }) => { export default new Template({}, ({ http, args }) => {
@ -71,18 +71,19 @@ export default new Template({}, ({ http, args }) => {
// 格式化文件大小 // 格式化文件大小
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return "0 Bytes";
const k = 1024; const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB']; const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}; };
// 获取文件图标 // 获取文件图标
const getFileIcon = (fileType: string) => { const getFileIcon = (fileType: string) => {
if (fileType.startsWith('image/')) return <ImageIcon className="w-4 h-4" />; 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("video/")) return <VideoIcon className="w-4 h-4" />;
if (fileType.startsWith('text/')) return <FileTextIcon className="w-4 h-4" />; if (fileType.startsWith("text/"))
return <FileTextIcon className="w-4 h-4" />;
return <FileIcon className="w-4 h-4" />; return <FileIcon className="w-4 h-4" />;
}; };
@ -94,11 +95,9 @@ export default new Template({}, ({ http, args }) => {
<Heading size="6" className="text-[--gray-12] mb-2"> <Heading size="6" className="text-[--gray-12] mb-2">
</Heading> </Heading>
<Text className="text-[--gray-11]"> <Text className="text-[--gray-11]"> {mockFiles.length} </Text>
{mockFiles.length}
</Text>
</Box> </Box>
<Button <Button
className="bg-[--accent-9]" className="bg-[--accent-9]"
onClick={() => setIsUploadDialogOpen(true)} onClick={() => setIsUploadDialogOpen(true)}
> >
@ -108,15 +107,14 @@ export default new Template({}, ({ http, args }) => {
</Flex> </Flex>
{/* 搜索和筛选栏 */} {/* 搜索和筛选栏 */}
<Flex <Flex gap="4" className="mb-6 flex-col sm:flex-row">
gap="4"
className="mb-6 flex-col sm:flex-row"
>
<Box className="w-full sm:w-64"> <Box className="w-full sm:w-64">
<TextField.Root <TextField.Root
placeholder="搜索文件..." placeholder="搜索文件..."
value={searchTerm} value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
> >
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
@ -127,20 +125,20 @@ export default new Template({}, ({ http, args }) => {
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<Button variant="surface"> <Button variant="surface">
: {selectedType === 'all' ? '全部' : selectedType} : {selectedType === "all" ? "全部" : selectedType}
</Button> </Button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Item onClick={() => setSelectedType('all')}> <DropdownMenu.Item onClick={() => setSelectedType("all")}>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => setSelectedType('documents')}> <DropdownMenu.Item onClick={() => setSelectedType("documents")}>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => setSelectedType('images')}> <DropdownMenu.Item onClick={() => setSelectedType("images")}>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => setSelectedType('others')}> <DropdownMenu.Item onClick={() => setSelectedType("others")}>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
@ -172,12 +170,10 @@ export default new Template({}, ({ http, args }) => {
<Text className="font-medium">{file.name}</Text> <Text className="font-medium">{file.name}</Text>
</Flex> </Flex>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>{formatFileSize(file.sizeBytes)}</Table.Cell>
{formatFileSize(file.sizeBytes)}
</Table.Cell>
<Table.Cell> <Table.Cell>
<Text className="text-[--gray-11]"> <Text className="text-[--gray-11]">
{file.fileType.split('/')[1].toUpperCase()} {file.fileType.split("/")[1].toUpperCase()}
</Text> </Text>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
@ -202,7 +198,10 @@ export default new Template({}, ({ http, args }) => {
{/* 移动端列表视图 */} {/* 移动端列表视图 */}
<div className="block sm:hidden"> <div className="block sm:hidden">
{mockFiles.map((file) => ( {mockFiles.map((file) => (
<DataList.Root key={file.id} className="p-4 border-b border-[--gray-6] last:border-b-0"> <DataList.Root
key={file.id}
className="p-4 border-b border-[--gray-6] last:border-b-0"
>
<DataList.Item> <DataList.Item>
<DataList.Label minWidth="88px"></DataList.Label> <DataList.Label minWidth="88px"></DataList.Label>
<DataList.Value> <DataList.Value>
@ -223,7 +222,7 @@ export default new Template({}, ({ http, args }) => {
<DataList.Item> <DataList.Item>
<DataList.Label minWidth="88px"></DataList.Label> <DataList.Label minWidth="88px"></DataList.Label>
<DataList.Value> <DataList.Value>
{file.fileType.split('/')[1].toUpperCase()} {file.fileType.split("/")[1].toUpperCase()}
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
@ -254,7 +253,10 @@ export default new Template({}, ({ http, args }) => {
</Box> </Box>
{/* 上传对话框 */} {/* 上传对话框 */}
<Dialog.Root open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}> <Dialog.Root
open={isUploadDialogOpen}
onOpenChange={setIsUploadDialogOpen}
>
<Dialog.Content style={{ maxWidth: 450 }}> <Dialog.Content style={{ maxWidth: 450 }}>
<Dialog.Title></Dialog.Title> <Dialog.Title></Dialog.Title>
<Dialog.Description size="2" mb="4"> <Dialog.Description size="2" mb="4">
@ -272,10 +274,7 @@ export default new Template({}, ({ http, args }) => {
console.log(e.target.files); console.log(e.target.files);
}} }}
/> />
<label <label htmlFor="file-upload" className="cursor-pointer">
htmlFor="file-upload"
className="cursor-pointer"
>
<FileIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" /> <FileIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" />
<Text className="text-[--gray-11] mb-2"> <Text className="text-[--gray-11] mb-2">
@ -293,13 +292,11 @@ export default new Template({}, ({ http, args }) => {
</Button> </Button>
</Dialog.Close> </Dialog.Close>
<Dialog.Close> <Dialog.Close>
<Button className="bg-[--accent-9]"> <Button className="bg-[--accent-9]"></Button>
</Button>
</Dialog.Close> </Dialog.Close>
</Flex> </Flex>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
</Box> </Box>
); );
}); });

View File

@ -100,13 +100,13 @@ export default new Template({}, ({ http, args }) => {
{stat.trend} {stat.trend}
</Text> </Text>
</Box> </Box>
<Box <Box
className="w-10 h-10 rounded-full flex items-center justify-center" className="w-10 h-10 rounded-full flex items-center justify-center"
style={{ backgroundColor: `color-mix(in srgb, ${stat.color} 15%, transparent)` }} style={{
backgroundColor: `color-mix(in srgb, ${stat.color} 15%, transparent)`,
}}
> >
<Box style={{ color: stat.color }}> <Box style={{ color: stat.color }}>{stat.icon}</Box>
{stat.icon}
</Box>
</Box> </Box>
</Flex> </Flex>
</Card> </Card>
@ -120,7 +120,7 @@ export default new Template({}, ({ http, args }) => {
</Heading> </Heading>
<Box className="space-y-4"> <Box className="space-y-4">
{recentPosts.map((post, index) => ( {recentPosts.map((post, index) => (
<Box <Box
key={index} key={index}
className="p-3 rounded-lg border border-[--gray-6] hover:border-[--accent-9] transition-colors cursor-pointer" className="p-3 rounded-lg border border-[--gray-6] hover:border-[--accent-9] transition-colors cursor-pointer"
> >
@ -150,14 +150,15 @@ export default new Template({}, ({ http, args }) => {
</Flex> </Flex>
</Flex> </Flex>
</Box> </Box>
<Box <Box
className={`px-2 py-1 rounded-full text-xs className={`px-2 py-1 rounded-full text-xs
${post.status === 'published' ${
? 'bg-[--green-3] text-[--green-11]' post.status === "published"
: 'bg-[--gray-3] text-[--gray-11]' ? "bg-[--green-3] text-[--green-11]"
: "bg-[--gray-3] text-[--gray-11]"
}`} }`}
> >
{post.status === 'published' ? '已发布' : '草稿'} {post.status === "published" ? "已发布" : "草稿"}
</Box> </Box>
</Flex> </Flex>
</Box> </Box>
@ -166,4 +167,4 @@ export default new Template({}, ({ http, args }) => {
</Card> </Card>
</Box> </Box>
); );
}); });

View File

@ -159,7 +159,7 @@ export default new Layout(({ children }) => {
{/* 主内容区域 */} {/* 主内容区域 */}
<Box className="flex-1 flex flex-col lg:ml-0 w-full relative"> <Box className="flex-1 flex flex-col lg:ml-0 w-full relative">
{/* 顶部导航栏 */} {/* 顶部导航栏 */}
<Box <Box
className={` className={`
h-16 border-b border-[--gray-6] bg-[--gray-1] h-16 border-b border-[--gray-6] bg-[--gray-1]
sticky top-0 z-20 w-full sticky top-0 z-20 w-full
@ -191,7 +191,7 @@ export default new Layout(({ children }) => {
<HamburgerMenuIcon className="w-5 h-5" /> <HamburgerMenuIcon className="w-5 h-5" />
</Button> </Button>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="3" size="3"
@ -209,12 +209,12 @@ export default new Layout(({ children }) => {
</Box> </Box>
{/* 返回主页按钮 */} {/* 返回主页按钮 */}
<Button <Button
variant="ghost" variant="ghost"
size="3" size="3"
className="gap-2 text-base" className="gap-2 text-base"
onClick={() => { onClick={() => {
window.location.href = '/'; window.location.href = "/";
}} }}
> >
<HomeIcon className="w-5 h-5" /> <HomeIcon className="w-5 h-5" />
@ -222,13 +222,13 @@ export default new Layout(({ children }) => {
</Button> </Button>
{/* 退出登录按钮 */} {/* 退出登录按钮 */}
<Button <Button
variant="ghost" variant="ghost"
size="3" size="3"
className="gap-2 text-base" className="gap-2 text-base"
onClick={() => { onClick={() => {
// 这里添加退出登录的逻辑 // 这里添加退出登录的逻辑
console.log('退出登录'); console.log("退出登录");
}} }}
> >
<ExitIcon className="w-5 h-5" /> <ExitIcon className="w-5 h-5" />
@ -239,14 +239,11 @@ export default new Layout(({ children }) => {
</Box> </Box>
{/* 页面内容区域 */} {/* 页面内容区域 */}
<Box <Box
id="main-content" id="main-content"
className="flex-1 overflow-y-auto bg-[--gray-2]" className="flex-1 overflow-y-auto bg-[--gray-2]"
> >
<Container <Container size="4" className="py-6 px-4">
size="4"
className="py-6 px-4"
>
{children} {children}
</Container> </Container>
</Box> </Box>

View File

@ -4,9 +4,9 @@ import { Container, Heading, Text, Box, Flex, Button } from "@radix-ui/themes";
import { PersonIcon, LockClosedIcon } from "@radix-ui/react-icons"; import { PersonIcon, LockClosedIcon } from "@radix-ui/react-icons";
import { useEffect, useRef, useState, useMemo } from "react"; import { useEffect, useRef, useState, useMemo } from "react";
import { gsap } from "gsap"; import { gsap } from "gsap";
import { AnimatedBackground } from 'hooks/Background'; import { AnimatedBackground } from "hooks/Background";
import { useThemeMode, ThemeModeToggle } from 'hooks/ThemeMode'; import { useThemeMode, ThemeModeToggle } from "hooks/ThemeMode";
import { useNotification } from 'hooks/Notification'; import { useNotification } from "hooks/Notification";
export default new Template({}, ({ http, args }) => { export default new Template({}, ({ http, args }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -59,16 +59,16 @@ export default new Template({}, ({ http, args }) => {
try { try {
// 这里添加登录逻辑 // 这里添加登录逻辑
await new Promise(resolve => setTimeout(resolve, 1500)); // 模拟API请求 await new Promise((resolve) => setTimeout(resolve, 1500)); // 模拟API请求
// 登录成功的通知 // 登录成功的通知
notification.success('登录成功', '欢迎回来!'); notification.success("登录成功", "欢迎回来!");
// 登录成功后的处理 // 登录成功后的处理
console.log("Login successful"); console.log("Login successful");
} catch (error) { } catch (error) {
// 登录失败的通知 // 登录失败的通知
notification.error('登录失败', '用户名或密码错误'); notification.error("登录失败", "用户名或密码错误");
console.error("Login failed:", error); console.error("Login failed:", error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -76,29 +76,35 @@ export default new Template({}, ({ http, args }) => {
}; };
const handleBackgroundError = () => { const handleBackgroundError = () => {
console.log('Background failed to load, switching to fallback'); console.log("Background failed to load, switching to fallback");
setHasBackgroundError(true); setHasBackgroundError(true);
}; };
// 使用 useMemo 包裹背景组件 // 使用 useMemo 包裹背景组件
const backgroundComponent = useMemo(() => ( const backgroundComponent = useMemo(
!hasBackgroundError && <AnimatedBackground onError={handleBackgroundError} /> () =>
), [hasBackgroundError]); !hasBackgroundError && (
<AnimatedBackground onError={handleBackgroundError} />
),
[hasBackgroundError],
);
return ( return (
<div className="relative min-h-screen"> <div className="relative min-h-screen">
{backgroundComponent} {backgroundComponent}
<Box <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" 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={{ style={
'--button-color': 'var(--gray-12)', {
'--button-hover-color': 'var(--accent-9)' "--button-color": "var(--gray-12)",
} as React.CSSProperties} "--button-hover-color": "var(--accent-9)",
} as React.CSSProperties
}
> >
<ThemeModeToggle /> <ThemeModeToggle />
</Box> </Box>
<Container <Container
ref={containerRef} ref={containerRef}
className={`relative z-10 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 ${
@ -106,11 +112,14 @@ export default new Template({}, ({ http, args }) => {
}`} }`}
> >
<Box className="w-full max-w-md mx-auto px-4"> <Box className="w-full max-w-md mx-auto px-4">
<Box <Box
className="login-box backdrop-blur-sm rounded-lg shadow-lg p-8 border transition-colors duration-300" className="login-box backdrop-blur-sm rounded-lg shadow-lg p-8 border transition-colors duration-300"
style={{ style={{
backgroundColor: mode === 'dark' ? 'var(--gray-2-alpha-80)' : 'var(--white-alpha-80)', backgroundColor:
borderColor: 'var(--gray-6)' mode === "dark"
? "var(--gray-2-alpha-80)"
: "var(--white-alpha-80)",
borderColor: "var(--gray-6)",
}} }}
> >
{/* Logo */} {/* Logo */}
@ -118,7 +127,6 @@ export default new Template({}, ({ http, args }) => {
<Heading size="6" className="text-center mb-2"> <Heading size="6" className="text-center mb-2">
</Heading> </Heading>
</Flex> </Flex>
{/* 登录表单 */} {/* 登录表单 */}
@ -151,11 +159,13 @@ export default new Template({}, ({ http, args }) => {
{/* 登录按钮 */} {/* 登录按钮 */}
<Button <Button
className="login-button w-full h-10 transition-colors duration-300 hover:bg-[--hover-bg]" className="login-button w-full h-10 transition-colors duration-300 hover:bg-[--hover-bg]"
style={{ style={
backgroundColor: 'var(--accent-9)', {
color: 'white', backgroundColor: "var(--accent-9)",
'--hover-bg': 'var(--accent-10)' color: "white",
} as React.CSSProperties} "--hover-bg": "var(--accent-10)",
} as React.CSSProperties
}
size="3" size="3"
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
@ -165,13 +175,15 @@ export default new Template({}, ({ http, args }) => {
{/* 其他选项 */} {/* 其他选项 */}
<Flex justify="center" className="form-element"> <Flex justify="center" className="form-element">
<Text <Text
size="2" size="2"
className="cursor-pointer transition-colors duration-300 hover:text-[--hover-color]" className="cursor-pointer transition-colors duration-300 hover:text-[--hover-color]"
style={{ style={
color: 'var(--gray-11)', {
'--hover-color': 'var(--accent-9)' color: "var(--gray-11)",
} as React.CSSProperties} "--hover-color": "var(--accent-9)",
} as React.CSSProperties
}
> >
</Text> </Text>
@ -183,4 +195,4 @@ export default new Template({}, ({ http, args }) => {
</Container> </Container>
</div> </div>
); );
}); });

View File

@ -1,10 +1,10 @@
import { Template } from "interface/template"; import { Template } from "interface/template";
import { import {
Container, Container,
Heading, Heading,
Text, Text,
Box, Box,
Flex, Flex,
Card, Card,
Button, Button,
TextField, TextField,
@ -13,7 +13,7 @@ import {
Dialog, Dialog,
Tabs, Tabs,
Switch, Switch,
IconButton IconButton,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { import {
PlusIcon, PlusIcon,
@ -25,13 +25,17 @@ import {
CheckIcon, CheckIcon,
UpdateIcon, UpdateIcon,
TrashIcon, TrashIcon,
ExclamationTriangleIcon ExclamationTriangleIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useState } from "react"; import { useState } from "react";
import type { PluginConfig } from "interface/plugin"; import type { PluginConfig } from "interface/plugin";
// 模拟插件数据 // 模拟插件数据
const mockPlugins: (PluginConfig & { id: number; preview?: string; installed?: boolean })[] = [ const mockPlugins: (PluginConfig & {
id: number;
preview?: string;
installed?: boolean;
})[] = [
{ {
id: 1, id: 1,
name: "comment-system", name: "comment-system",
@ -41,7 +45,8 @@ const mockPlugins: (PluginConfig & { id: number; preview?: string; installed?: b
author: "Admin", author: "Admin",
enabled: true, enabled: true,
icon: "https://api.iconify.design/material-symbols:comment.svg", icon: "https://api.iconify.design/material-symbols:comment.svg",
preview: "https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format", preview:
"https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format",
managePath: "/dashboard/plugins/comment-system", managePath: "/dashboard/plugins/comment-system",
installed: true, installed: true,
configuration: { configuration: {
@ -51,11 +56,11 @@ const mockPlugins: (PluginConfig & { id: number; preview?: string; installed?: b
data: { data: {
provider: "gitalk", provider: "gitalk",
clientId: "", clientId: "",
clientSecret: "" clientSecret: "",
} },
} },
}, },
routes: new Set() routes: new Set(),
}, },
{ {
id: 2, id: 2,
@ -66,7 +71,8 @@ const mockPlugins: (PluginConfig & { id: number; preview?: string; installed?: b
author: "ThirdParty", author: "ThirdParty",
enabled: false, enabled: false,
icon: "https://api.iconify.design/material-symbols:image.svg", icon: "https://api.iconify.design/material-symbols:image.svg",
preview: "https://images.unsplash.com/photo-1618005198919-d3d4b5a92ead?w=500&auto=format", preview:
"https://images.unsplash.com/photo-1618005198919-d3d4b5a92ead?w=500&auto=format",
installed: true, installed: true,
configuration: { configuration: {
system: { system: {
@ -75,12 +81,12 @@ const mockPlugins: (PluginConfig & { id: number; preview?: string; installed?: b
data: { data: {
quality: 80, quality: 80,
maxWidth: 1920, maxWidth: 1920,
watermark: false watermark: false,
} },
} },
}, },
routes: new Set() routes: new Set(),
} },
]; ];
// 模拟市场插件数据 // 模拟市场插件数据
@ -104,9 +110,10 @@ const marketPlugins: MarketPlugin[] = [
version: "1.0.0", version: "1.0.0",
description: "自动优化上传的图片,支持压缩、裁剪、水印等功能", description: "自动优化上传的图片,支持压缩、裁剪、水印等功能",
author: "ThirdParty", author: "ThirdParty",
preview: "https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format", preview:
"https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format",
downloads: 1200, downloads: 1200,
rating: 4.5 rating: 4.5,
}, },
{ {
id: 5, id: 5,
@ -115,22 +122,25 @@ const marketPlugins: MarketPlugin[] = [
version: "2.0.0", version: "2.0.0",
description: "增强的Markdown编辑器支持更多扩展语法和实时预览", description: "增强的Markdown编辑器支持更多扩展语法和实时预览",
author: "ThirdParty", author: "ThirdParty",
preview: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=500&auto=format", preview:
"https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=500&auto=format",
downloads: 3500, downloads: 3500,
rating: 4.8 rating: 4.8,
} },
]; ];
export default new Template({}, ({ http, args }) => { export default new Template({}, ({ http, args }) => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<typeof mockPlugins[0] | null>(null); const [selectedPlugin, setSelectedPlugin] = useState<
(typeof mockPlugins)[0] | null
>(null);
// 处理插件启用/禁用 // 处理插件启用/禁用
const handleTogglePlugin = (pluginId: number) => { const handleTogglePlugin = (pluginId: number) => {
// 这里添加启用/禁用插件的逻辑 // 这里添加启用/禁用插件的逻辑
console.log('Toggle plugin:', pluginId); console.log("Toggle plugin:", pluginId);
}; };
return ( return (
@ -145,7 +155,7 @@ export default new Template({}, ({ http, args }) => {
{mockPlugins.length} {mockPlugins.length}
</Text> </Text>
</Box> </Box>
<Button <Button
className="bg-[--accent-9]" className="bg-[--accent-9]"
onClick={() => setIsAddDialogOpen(true)} onClick={() => setIsAddDialogOpen(true)}
> >
@ -156,10 +166,12 @@ export default new Template({}, ({ http, args }) => {
{/* 搜索栏 */} {/* 搜索栏 */}
<Box className="w-full sm:w-64 mb-6"> <Box className="w-full sm:w-64 mb-6">
<TextField.Root <TextField.Root
placeholder="搜索插件..." placeholder="搜索插件..."
value={searchTerm} value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
> >
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
@ -170,12 +182,15 @@ export default new Template({}, ({ http, args }) => {
{/* 插件列表 */} {/* 插件列表 */}
<Box className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <Box className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{mockPlugins.map((plugin) => ( {mockPlugins.map((plugin) => (
<Card key={plugin.id} className="p-4 border border-[--gray-6] hover-card"> <Card
key={plugin.id}
className="p-4 border border-[--gray-6] hover-card"
>
{/* 插件预览图 */} {/* 插件预览图 */}
{plugin.preview && ( {plugin.preview && (
<Box className="aspect-video mb-4 rounded-lg overflow-hidden bg-[--gray-3]"> <Box className="aspect-video mb-4 rounded-lg overflow-hidden bg-[--gray-3]">
<img <img
src={plugin.preview} src={plugin.preview}
alt={plugin.displayName} alt={plugin.displayName}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@ -186,7 +201,7 @@ export default new Template({}, ({ http, args }) => {
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Flex justify="between" align="center"> <Flex justify="between" align="center">
<Heading size="3">{plugin.displayName}</Heading> <Heading size="3">{plugin.displayName}</Heading>
<Switch <Switch
checked={plugin.enabled} checked={plugin.enabled}
onCheckedChange={() => handleTogglePlugin(plugin.id)} onCheckedChange={() => handleTogglePlugin(plugin.id)}
/> />
@ -203,11 +218,11 @@ export default new Template({}, ({ http, args }) => {
{/* 操作按钮 */} {/* 操作按钮 */}
<Flex gap="2" mt="2"> <Flex gap="2" mt="2">
{plugin.managePath && plugin.enabled && ( {plugin.managePath && plugin.enabled && (
<Button <Button
variant="soft" variant="soft"
className="flex-1" className="flex-1"
onClick={() => { onClick={() => {
if(plugin.managePath) { if (plugin.managePath) {
window.location.href = plugin.managePath; window.location.href = plugin.managePath;
} }
}} }}
@ -216,11 +231,7 @@ export default new Template({}, ({ http, args }) => {
</Button> </Button>
)} )}
<Button <Button variant="soft" color="red" className="flex-1">
variant="soft"
color="red"
className="flex-1"
>
<TrashIcon className="w-4 h-4" /> <TrashIcon className="w-4 h-4" />
</Button> </Button>
@ -249,10 +260,7 @@ export default new Template({}, ({ http, args }) => {
console.log(e.target.files); console.log(e.target.files);
}} }}
/> />
<label <label htmlFor="plugin-upload" className="cursor-pointer">
htmlFor="plugin-upload"
className="cursor-pointer"
>
<CodeIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" /> <CodeIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" />
<Text className="text-[--gray-11] mb-2"> <Text className="text-[--gray-11] mb-2">
@ -271,13 +279,11 @@ export default new Template({}, ({ http, args }) => {
</Button> </Button>
</Dialog.Close> </Dialog.Close>
<Dialog.Close> <Dialog.Close>
<Button className="bg-[--accent-9]"> <Button className="bg-[--accent-9]"></Button>
</Button>
</Dialog.Close> </Dialog.Close>
</Flex> </Flex>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
</Box> </Box>
); );
}); });

View File

@ -1,17 +1,17 @@
import { Template } from "interface/template"; import { Template } from "interface/template";
import { import {
Container, Container,
Heading, Heading,
Text, Text,
Box, Box,
Flex, Flex,
Table, Table,
Button, Button,
TextField, TextField,
DropdownMenu, DropdownMenu,
ScrollArea, ScrollArea,
DataList, DataList,
Badge Badge,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { import {
PlusIcon, PlusIcon,
@ -40,7 +40,7 @@ const mockPosts: PostDisplay[] = [
metaKeywords: "", metaKeywords: "",
metaDescription: "", metaDescription: "",
categories: [{ name: "前端开发" }], categories: [{ name: "前端开发" }],
tags: [{ name: "工程化" }, { name: "效率提升" }] tags: [{ name: "工程化" }, { name: "效率提升" }],
}, },
// ... 可以添加更多模拟数据 // ... 可以添加更多模拟数据
]; ];
@ -63,15 +63,17 @@ export default new Template({}, ({ http, args }) => {
</Flex> </Flex>
{/* 搜索和筛选栏 */} {/* 搜索和筛选栏 */}
<Flex <Flex
gap="4" gap="4"
className="mb-6 flex-col sm:flex-row" // 移动端垂直布局,桌面端水平布局 className="mb-6 flex-col sm:flex-row" // 移动端垂直布局,桌面端水平布局
> >
<Box className="w-full sm:w-64"> <Box className="w-full sm:w-64">
<TextField.Root <TextField.Root
placeholder="搜索文章..." placeholder="搜索文章..."
value={searchTerm} value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
> >
<TextField.Slot side="right"> <TextField.Slot side="right">
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
@ -82,17 +84,17 @@ export default new Template({}, ({ http, args }) => {
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<Button variant="surface"> <Button variant="surface">
: {selectedStatus === 'all' ? '全部' : selectedStatus} : {selectedStatus === "all" ? "全部" : selectedStatus}
</Button> </Button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Item onClick={() => setSelectedStatus('all')}> <DropdownMenu.Item onClick={() => setSelectedStatus("all")}>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => setSelectedStatus('published')}> <DropdownMenu.Item onClick={() => setSelectedStatus("published")}>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => setSelectedStatus('draft')}> <DropdownMenu.Item onClick={() => setSelectedStatus("draft")}>
稿 稿
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
@ -117,7 +119,10 @@ export default new Template({}, ({ http, args }) => {
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{mockPosts.map((post) => ( {mockPosts.map((post) => (
<Table.Row key={post.id} className="hover:bg-[--gray-3] block sm:table-row mb-4 sm:mb-0"> <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"> <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} {post.title}
</Table.Cell> </Table.Cell>
@ -127,7 +132,7 @@ export default new Template({}, ({ http, args }) => {
<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"> <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"> <Flex gap="2" className="inline-flex">
{post.categories?.map((category) => ( {post.categories?.map((category) => (
<Text <Text
key={category.name} key={category.name}
size="1" size="1"
className="px-2 py-0.5 bg-[--gray-4] rounded" className="px-2 py-0.5 bg-[--gray-4] rounded"
@ -139,7 +144,7 @@ export default new Template({}, ({ http, args }) => {
</Table.Cell> </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"> <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"> <Flex gap="2">
{post.status === 'published' ? ( {post.status === "published" ? (
<Badge color="green"></Badge> <Badge color="green"></Badge>
) : ( ) : (
<Badge color="orange">稿</Badge> <Badge color="orange">稿</Badge>
@ -171,7 +176,10 @@ export default new Template({}, ({ http, args }) => {
{/* 移动端列表视图 */} {/* 移动端列表视图 */}
<div className="block sm:hidden"> <div className="block sm:hidden">
{mockPosts.map((post) => ( {mockPosts.map((post) => (
<DataList.Root key={post.id} className="p-4 border-b border-[--gray-6] last:border-b-0"> <DataList.Root
key={post.id}
className="p-4 border-b border-[--gray-6] last:border-b-0"
>
<DataList.Item> <DataList.Item>
<DataList.Label minWidth="88px"></DataList.Label> <DataList.Label minWidth="88px"></DataList.Label>
<DataList.Value> <DataList.Value>
@ -189,7 +197,7 @@ export default new Template({}, ({ http, args }) => {
<DataList.Value> <DataList.Value>
<Flex gap="2"> <Flex gap="2">
{post.categories?.map((category) => ( {post.categories?.map((category) => (
<Text <Text
key={category.name} key={category.name}
size="1" size="1"
className="px-2 py-0.5 bg-[--gray-4] rounded" className="px-2 py-0.5 bg-[--gray-4] rounded"
@ -205,7 +213,7 @@ export default new Template({}, ({ http, args }) => {
<DataList.Label minWidth="88px"></DataList.Label> <DataList.Label minWidth="88px"></DataList.Label>
<DataList.Value> <DataList.Value>
<Flex gap="2"> <Flex gap="2">
{post.status === 'published' ? ( {post.status === "published" ? (
<Badge color="green"></Badge> <Badge color="green"></Badge>
) : ( ) : (
<Badge color="orange">稿</Badge> <Badge color="orange">稿</Badge>
@ -244,4 +252,4 @@ export default new Template({}, ({ http, args }) => {
</Box> </Box>
</Box> </Box>
); );
}); });

View File

@ -1,23 +1,23 @@
import { Template } from "interface/template"; import { Template } from "interface/template";
import { import {
Container, Container,
Heading, Heading,
Text, Text,
Box, Box,
Flex, Flex,
Card, Card,
Button, Button,
TextField, TextField,
Switch, Switch,
Tabs, Tabs,
TextArea TextArea,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { import {
GearIcon, GearIcon,
PersonIcon, PersonIcon,
LockClosedIcon, LockClosedIcon,
BellIcon, BellIcon,
GlobeIcon GlobeIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useState } from "react"; import { useState } from "react";
@ -62,7 +62,9 @@ export default new Template({}, ({ http, args }) => {
</Text> </Text>
<TextField.Root <TextField.Root
value={siteName} value={siteName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSiteName(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSiteName(e.target.value)
}
/> />
</Box> </Box>
@ -72,7 +74,9 @@ export default new Template({}, ({ http, args }) => {
</Text> </Text>
<TextArea <TextArea
value={siteDescription} value={siteDescription}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setSiteDescription(e.target.value)} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setSiteDescription(e.target.value)
}
className="min-h-[100px]" className="min-h-[100px]"
/> />
</Box> </Box>
@ -81,9 +85,7 @@ export default new Template({}, ({ http, args }) => {
<Text as="label" size="2" weight="bold" className="block mb-2"> <Text as="label" size="2" weight="bold" className="block mb-2">
</Text> </Text>
<select <select className="w-full h-9 px-3 rounded-md bg-[--gray-1] border border-[--gray-6] text-[--gray-12]">
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="zh-CN"></option>
<option value="en-US">English</option> <option value="en-US">English</option>
</select> </select>
@ -93,9 +95,7 @@ export default new Template({}, ({ http, args }) => {
<Text as="label" size="2" weight="bold" className="block mb-2"> <Text as="label" size="2" weight="bold" className="block mb-2">
</Text> </Text>
<select <select className="w-full h-9 px-3 rounded-md bg-[--gray-1] border border-[--gray-6] text-[--gray-12]">
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+8">UTC+8 </option>
<option value="UTC+0">UTC+0 </option> <option value="UTC+0">UTC+0 </option>
</select> </select>
@ -156,18 +156,9 @@ export default new Template({}, ({ http, args }) => {
</Text> </Text>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<TextField.Root <TextField.Root type="password" placeholder="当前密码" />
type="password" <TextField.Root type="password" placeholder="新密码" />
placeholder="当前密码" <TextField.Root type="password" placeholder="确认新密码" />
/>
<TextField.Root
type="password"
placeholder="新密码"
/>
<TextField.Root
type="password"
placeholder="确认新密码"
/>
</Flex> </Flex>
</Box> </Box>
@ -196,7 +187,7 @@ export default new Template({}, ({ http, args }) => {
</Text> </Text>
</Box> </Box>
<Switch <Switch
checked={emailNotifications} checked={emailNotifications}
onCheckedChange={setEmailNotifications} onCheckedChange={setEmailNotifications}
/> />
@ -221,10 +212,8 @@ export default new Template({}, ({ http, args }) => {
{/* 保存按钮 */} {/* 保存按钮 */}
<Flex justify="end" className="mt-6"> <Flex justify="end" className="mt-6">
<Button className="bg-[--accent-9]"> <Button className="bg-[--accent-9]"></Button>
</Button>
</Flex> </Flex>
</Box> </Box>
); );
}); });

View File

@ -1,10 +1,10 @@
import { Template } from "interface/template"; import { Template } from "interface/template";
import { import {
Container, Container,
Heading, Heading,
Text, Text,
Box, Box,
Flex, Flex,
Card, Card,
Button, Button,
TextField, TextField,
@ -25,7 +25,11 @@ import { useState } from "react";
import type { ThemeConfig } from "interface/theme"; import type { ThemeConfig } from "interface/theme";
// 模拟主题数据 // 模拟主题数据
const mockThemes: (ThemeConfig & { id: number; preview: string; active: boolean })[] = [ const mockThemes: (ThemeConfig & {
id: number;
preview: string;
active: boolean;
})[] = [
{ {
id: 1, id: 1,
name: "echoes", name: "echoes",
@ -33,7 +37,8 @@ const mockThemes: (ThemeConfig & { id: number; preview: string; active: boolean
version: "1.0.0", version: "1.0.0",
description: "默认主题", description: "默认主题",
author: "Admin", author: "Admin",
preview: "https://images.unsplash.com/photo-1481487196290-c152efe083f5?w=500&auto=format", preview:
"https://images.unsplash.com/photo-1481487196290-c152efe083f5?w=500&auto=format",
templates: new Map(), templates: new Map(),
configuration: { configuration: {
theme: { theme: {
@ -42,44 +47,47 @@ const mockThemes: (ThemeConfig & { id: number; preview: string; active: boolean
data: { data: {
colors: { colors: {
mode: "light", mode: "light",
layout: "default" layout: "default",
} },
} },
} },
}, },
routes: new Map(), routes: new Map(),
active: true active: true,
}, },
{ {
id: 2, id: 2,
name: "minimal", name: "minimal",
displayName: "Minimal", displayName: "Minimal",
version: "1.0.0", version: "1.0.0",
description: "简约风格主题", description: "简约风格主题",
author: "Admin", author: "Admin",
preview: "https://images.unsplash.com/photo-1618005198919-d3d4b5a92ead?w=500&auto=format", preview:
"https://images.unsplash.com/photo-1618005198919-d3d4b5a92ead?w=500&auto=format",
templates: new Map(), templates: new Map(),
configuration: { configuration: {
theme: { theme: {
title: "主题配置", title: "主题配置",
description: "Echoes主题配置项", description: "Echoes主题配置项",
data: { data: {
colors: { colors: {
mode: "light", mode: "light",
layout: "default" layout: "default",
} },
} },
} },
}, },
routes: new Map(), routes: new Map(),
active: false active: false,
} },
]; ];
export default new Template({}, ({ http, args }) => { export default new Template({}, ({ http, args }) => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [selectedTheme, setSelectedTheme] = useState<typeof mockThemes[0] | null>(null); const [selectedTheme, setSelectedTheme] = useState<
(typeof mockThemes)[0] | null
>(null);
return ( return (
<Box> <Box>
@ -93,7 +101,7 @@ export default new Template({}, ({ http, args }) => {
{mockThemes.length} {mockThemes.length}
</Text> </Text>
</Box> </Box>
<Button <Button
className="bg-[--accent-9]" className="bg-[--accent-9]"
onClick={() => setIsAddDialogOpen(true)} onClick={() => setIsAddDialogOpen(true)}
> >
@ -114,11 +122,14 @@ export default new Template({}, ({ http, args }) => {
{/* 主题列表 */} {/* 主题列表 */}
<Box className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <Box className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{mockThemes.map((theme) => ( {mockThemes.map((theme) => (
<Card key={theme.id} className="p-4 border border-[--gray-6] hover-card"> <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]"> <Box className="aspect-video mb-4 rounded-lg overflow-hidden bg-[--gray-3]">
<img <img
src={theme.preview} src={theme.preview}
alt={theme.displayName} alt={theme.displayName}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@ -129,7 +140,10 @@ export default new Template({}, ({ http, args }) => {
<Flex justify="between" align="center"> <Flex justify="between" align="center">
<Heading size="3">{theme.displayName}</Heading> <Heading size="3">{theme.displayName}</Heading>
{theme.active && ( {theme.active && (
<Text size="1" className="px-2 py-1 bg-[--accent-3] text-[--accent-9] rounded"> <Text
size="1"
className="px-2 py-1 bg-[--accent-3] text-[--accent-9] rounded"
>
使 使
</Text> </Text>
)} )}
@ -146,29 +160,29 @@ export default new Template({}, ({ http, args }) => {
{/* 操作按钮 */} {/* 操作按钮 */}
<Flex gap="2" mt="2"> <Flex gap="2" mt="2">
{theme.active ? ( {theme.active ? (
<Button <Button
variant="soft" variant="soft"
className="flex-1" className="flex-1"
onClick={() => window.location.href = `/dashboard/themes/${theme.name}/settings`} onClick={() =>
(window.location.href = `/dashboard/themes/${theme.name}/settings`)
}
> >
<GearIcon className="w-4 h-4" /> <GearIcon className="w-4 h-4" />
</Button> </Button>
) : ( ) : (
<> <>
<Button <Button className="flex-1 bg-[--accent-9]">
className="flex-1 bg-[--accent-9]"
>
<CheckIcon className="w-4 h-4" /> <CheckIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
variant="soft" variant="soft"
color="red" color="red"
className="flex-1" className="flex-1"
onClick={() => { onClick={() => {
// 这里添加卸载主题的处理逻辑 // 这里添加卸载主题的处理逻辑
console.log('卸载主题:', theme.name); console.log("卸载主题:", theme.name);
}} }}
> >
<Cross2Icon className="w-4 h-4" /> <Cross2Icon className="w-4 h-4" />
@ -201,10 +215,7 @@ export default new Template({}, ({ http, args }) => {
console.log(e.target.files); console.log(e.target.files);
}} }}
/> />
<label <label htmlFor="theme-upload" className="cursor-pointer">
htmlFor="theme-upload"
className="cursor-pointer"
>
<CodeIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" /> <CodeIcon className="w-12 h-12 mx-auto mb-4 text-[--gray-9]" />
<Text className="text-[--gray-11] mb-2"> <Text className="text-[--gray-11] mb-2">
@ -227,4 +238,4 @@ export default new Template({}, ({ http, args }) => {
</Dialog.Root> </Dialog.Root>
</Box> </Box>
); );
}); });

View File

@ -1,10 +1,10 @@
import { Template } from "interface/template"; import { Template } from "interface/template";
import { import {
Container, Container,
Heading, Heading,
Text, Text,
Box, Box,
Flex, Flex,
Table, Table,
Button, Button,
TextField, TextField,
@ -12,7 +12,7 @@ import {
Dialog, Dialog,
Avatar, Avatar,
DropdownMenu, DropdownMenu,
Badge Badge,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { import {
PlusIcon, PlusIcon,
@ -22,7 +22,7 @@ import {
PersonIcon, PersonIcon,
DotsHorizontalIcon, DotsHorizontalIcon,
LockClosedIcon, LockClosedIcon,
ExclamationTriangleIcon ExclamationTriangleIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useState } from "react"; import { useState } from "react";
import type { User } from "interface/fields"; import type { User } from "interface/fields";
@ -55,36 +55,38 @@ const mockUsers: (User & { id: number })[] = [
id: 3, id: 3,
username: "user", username: "user",
email: "user@example.com", email: "user@example.com",
avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=3", avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=3",
role: "user", role: "user",
createdAt: new Date("2024-03-01"), createdAt: new Date("2024-03-01"),
updatedAt: new Date("2024-03-13"), updatedAt: new Date("2024-03-13"),
lastLoginAt: new Date("2024-03-13"), lastLoginAt: new Date("2024-03-13"),
passwordHash: "", passwordHash: "",
} },
]; ];
export default new Template({}, ({ http, args }) => { export default new Template({}, ({ http, args }) => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<typeof mockUsers[0] | null>(null); const [selectedUser, setSelectedUser] = useState<
(typeof mockUsers)[0] | null
>(null);
const [newUserData, setNewUserData] = useState({ const [newUserData, setNewUserData] = useState({
username: "", username: "",
email: "", email: "",
password: "", password: "",
role: "user" role: "user",
}); });
// 获取角色标签样式 // 获取角色标签样式
const getRoleBadgeColor = (role: string) => { const getRoleBadgeColor = (role: string) => {
switch(role) { switch (role) {
case 'admin': case "admin":
return 'red'; return "red";
case 'editor': case "editor":
return 'blue'; return "blue";
default: default:
return 'gray'; return "gray";
} }
}; };
@ -96,11 +98,9 @@ export default new Template({}, ({ http, args }) => {
<Heading size="6" className="text-[--gray-12] mb-2"> <Heading size="6" className="text-[--gray-12] mb-2">
</Heading> </Heading>
<Text className="text-[--gray-11]"> <Text className="text-[--gray-11]"> {mockUsers.length} </Text>
{mockUsers.length}
</Text>
</Box> </Box>
<Button <Button
className="bg-[--accent-9]" className="bg-[--accent-9]"
onClick={() => setIsAddDialogOpen(true)} onClick={() => setIsAddDialogOpen(true)}
> >
@ -111,10 +111,12 @@ export default new Template({}, ({ http, args }) => {
{/* 搜索栏 */} {/* 搜索栏 */}
<Box className="w-full sm:w-64 mb-6"> <Box className="w-full sm:w-64 mb-6">
<TextField.Root <TextField.Root
placeholder="搜索用户..." placeholder="搜索用户..."
value={searchTerm} value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
> >
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
@ -157,16 +159,14 @@ export default new Template({}, ({ http, args }) => {
{user.role} {user.role}
</Badge> </Badge>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>{user.createdAt.toLocaleDateString()}</Table.Cell>
{user.createdAt.toLocaleDateString()}
</Table.Cell>
<Table.Cell> <Table.Cell>
{user.lastLoginAt?.toLocaleDateString()} {user.lastLoginAt?.toLocaleDateString()}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Flex gap="2"> <Flex gap="2">
<Button <Button
variant="ghost" variant="ghost"
size="1" size="1"
onClick={() => { onClick={() => {
setSelectedUser(user); setSelectedUser(user);
@ -203,8 +203,8 @@ export default new Template({}, ({ http, args }) => {
<TextField.Root <TextField.Root
placeholder="输入用户名" placeholder="输入用户名"
value={newUserData.username} value={newUserData.username}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNewUserData({...newUserData, username: e.target.value}) setNewUserData({ ...newUserData, username: e.target.value })
} }
/> />
</Box> </Box>
@ -216,8 +216,8 @@ export default new Template({}, ({ http, args }) => {
<TextField.Root <TextField.Root
placeholder="输入邮箱" placeholder="输入邮箱"
value={newUserData.email} value={newUserData.email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNewUserData({...newUserData, email: e.target.value}) setNewUserData({ ...newUserData, email: e.target.value })
} }
/> />
</Box> </Box>
@ -230,8 +230,8 @@ export default new Template({}, ({ http, args }) => {
type="password" type="password"
placeholder="输入密码" placeholder="输入密码"
value={newUserData.password} value={newUserData.password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNewUserData({...newUserData, password: e.target.value}) setNewUserData({ ...newUserData, password: e.target.value })
} }
/> />
</Box> </Box>
@ -243,7 +243,9 @@ export default new Template({}, ({ http, args }) => {
<select <select
className="w-full h-9 px-3 rounded-md bg-[--gray-1] border border-[--gray-6] text-[--gray-12]" className="w-full h-9 px-3 rounded-md bg-[--gray-1] border border-[--gray-6] text-[--gray-12]"
value={newUserData.role} value={newUserData.role}
onChange={(e) => setNewUserData({...newUserData, role: e.target.value})} onChange={(e) =>
setNewUserData({ ...newUserData, role: e.target.value })
}
> >
<option value="user"></option> <option value="user"></option>
<option value="editor"></option> <option value="editor"></option>
@ -259,9 +261,7 @@ export default new Template({}, ({ http, args }) => {
</Button> </Button>
</Dialog.Close> </Dialog.Close>
<Dialog.Close> <Dialog.Close>
<Button className="bg-[--accent-9]"> <Button className="bg-[--accent-9]"></Button>
</Button>
</Dialog.Close> </Dialog.Close>
</Flex> </Flex>
</Dialog.Content> </Dialog.Content>
@ -279,25 +279,36 @@ export default new Template({}, ({ http, args }) => {
<Flex direction="column" gap="4"> <Flex direction="column" gap="4">
<Box> <Box>
<Text as="label" size="2" weight="bold" className="block mb-2"> <Text
as="label"
size="2"
weight="bold"
className="block mb-2"
>
</Text> </Text>
<TextField.Root <TextField.Root defaultValue={selectedUser.username} />
defaultValue={selectedUser.username}
/>
</Box> </Box>
<Box> <Box>
<Text as="label" size="2" weight="bold" className="block mb-2"> <Text
as="label"
size="2"
weight="bold"
className="block mb-2"
>
</Text> </Text>
<TextField.Root <TextField.Root defaultValue={selectedUser.email} />
defaultValue={selectedUser.email}
/>
</Box> </Box>
<Box> <Box>
<Text as="label" size="2" weight="bold" className="block mb-2"> <Text
as="label"
size="2"
weight="bold"
className="block mb-2"
>
</Text> </Text>
<select <select
@ -311,13 +322,15 @@ export default new Template({}, ({ http, args }) => {
</Box> </Box>
<Box> <Box>
<Text as="label" size="2" weight="bold" className="block mb-2"> <Text
as="label"
size="2"
weight="bold"
className="block mb-2"
>
</Text> </Text>
<TextField.Root <TextField.Root type="password" placeholder="留空则不修改" />
type="password"
placeholder="留空则不修改"
/>
</Box> </Box>
</Flex> </Flex>
@ -328,9 +341,7 @@ export default new Template({}, ({ http, args }) => {
</Button> </Button>
</Dialog.Close> </Dialog.Close>
<Dialog.Close> <Dialog.Close>
<Button className="bg-[--accent-9]"> <Button className="bg-[--accent-9]"></Button>
</Button>
</Dialog.Close> </Dialog.Close>
</Flex> </Flex>
</> </>

View File

@ -9,8 +9,5 @@ import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client"; import { hydrateRoot } from "react-dom/client";
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(document, <RemixBrowser />);
document,
<RemixBrowser />,
);
}); });

View File

@ -490,16 +490,16 @@ export default function SetupPage() {
useEffect(() => { useEffect(() => {
// 标记客户端渲染完成 // 标记客户端渲染完成
setIsClient(true); setIsClient(true);
// 获取初始化状态 // 获取初始化状态
const initStatus = Number(import.meta.env.VITE_INIT_STATUS ?? 0); const initStatus = Number(import.meta.env.VITE_INIT_STATUS ?? 0);
// 如果已完成初始化,直接刷新页面 // 如果已完成初始化,直接刷新页面
if (initStatus >= 3) { if (initStatus >= 3) {
window.location.reload(); window.location.reload();
return; return;
} }
// 否则设置当前步骤 // 否则设置当前步骤
setCurrentStep(initStatus + 1); setCurrentStep(initStatus + 1);
}, []); }, []);

View File

@ -1,11 +0,0 @@
"use client";
import React from "react";
import GrowingTree from "../hooks/tide";
export default function Page() {
return (
<div style={{ position: "relative", minHeight: "100vh" }}>
<GrowingTree />
</div>
);
}

View File

@ -13,36 +13,13 @@ import "~/index.css";
export function Layout() { export function Layout() {
return ( return (
<html <html lang="en" className="h-full" suppressHydrationWarning={true}>
lang="en"
className="h-full"
suppressHydrationWarning={true}
>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta <meta httpEquiv="Expires" content="0" />
name="viewport"
content="width=device-width, initial-scale=1"
/>
<meta
name="generator"
content="echoes"
/>
<meta
httpEquiv="Cache-Control"
content="no-cache, no-store, must-revalidate"
/>
<meta
httpEquiv="Pragma"
content="no-cache"
/>
<meta
httpEquiv="Expires"
content="0"
/>
<title>Echoes</title> <title>Echoes</title>
<ThemeScript/> <ThemeScript />
<Meta /> <Meta />
<Links /> <Links />
</head> </head>
@ -51,11 +28,7 @@ export function Layout() {
suppressHydrationWarning={true} suppressHydrationWarning={true}
data-cz-shortcut-listen="false" data-cz-shortcut-listen="false"
> >
<Theme <Theme grayColor="slate" radius="medium" scaling="100%">
grayColor="slate"
radius="medium"
scaling="100%"
>
<NotificationProvider> <NotificationProvider>
<Outlet /> <Outlet />
</NotificationProvider> </NotificationProvider>
@ -70,4 +43,3 @@ export function Layout() {
export default function App() { export default function App() {
return <Layout />; return <Layout />;
} }

View File

@ -66,7 +66,7 @@ const Routes = memo(() => {
if (!subPath) { if (!subPath) {
return renderDashboardContent(dashboard); return renderDashboardContent(dashboard);
} }
// 根据子路径返回对应的管理页面 // 根据子路径返回对应的管理页面
switch (subPath) { switch (subPath) {
case "posts": case "posts":

View File

@ -1,3 +1,4 @@
import { DEFAULT_CONFIG } from "~/env";
export interface ErrorResponse { export interface ErrorResponse {
title: string; title: string;
message: string; message: string;
@ -73,18 +74,17 @@ export class HttpClient {
private async request<T>( private async request<T>(
endpoint: string, endpoint: string,
options: RequestInit = {}, options: RequestInit = {},
prefix = "api", url: string,
): Promise<T> { ): Promise<T> {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout); const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try { try {
const config = await this.setHeaders(options); const config = await this.setHeaders(options);
const url = endpoint.startsWith(`/__/${prefix}`) const baseUrl = url.startsWith("http") ? url : `http://${url}`;
? endpoint const newUrl = new URL(endpoint, baseUrl);
: `/__/${prefix}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`;
const response = await fetch(url, { const response = await fetch(newUrl, {
...config, ...config,
signal: controller.signal, signal: controller.signal,
credentials: "include", credentials: "include",
@ -116,11 +116,16 @@ export class HttpClient {
} }
public async api<T>(endpoint: string, options: RequestInit = {}): Promise<T> { public async api<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
return this.request<T>(endpoint, options, "api"); const url =
import.meta.env.VITE_API_BASE_URL ?? DEFAULT_CONFIG.VITE_API_BASE_URL;
return this.request<T>(endpoint, options, url);
} }
public async dev<T>(endpoint: string, options: RequestInit = {}): Promise<T> { public async dev<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
return this.request<T>(endpoint, options, "express"); const address = import.meta.env.VITE_ADDRESS ?? DEFAULT_CONFIG.VITE_ADDRESS;
const port =
Number(import.meta.env.VITE_PORT ?? DEFAULT_CONFIG.VITE_PORT) + 1;
return this.request<T>(endpoint, options, `${address}:${port}`);
} }
public async get<T>(endpoint: string, options: RequestInit = {}): Promise<T> { public async get<T>(endpoint: string, options: RequestInit = {}): Promise<T> {

View File

@ -1,119 +1,121 @@
import { useEffect, useRef, memo } from 'react'; import { useEffect, useRef, memo } from "react";
import { useThemeMode } from 'hooks/ThemeMode'; import { useThemeMode } from "hooks/ThemeMode";
interface AnimatedBackgroundProps { interface AnimatedBackgroundProps {
onError?: () => void; onError?: () => void;
} }
export const AnimatedBackground = memo(({ onError }: AnimatedBackgroundProps) => { export const AnimatedBackground = memo(
const canvasRef = useRef<HTMLCanvasElement>(null); ({ onError }: AnimatedBackgroundProps) => {
const { mode } = useThemeMode(); const canvasRef = useRef<HTMLCanvasElement>(null);
const { mode } = useThemeMode();
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current;
const canvas = canvasRef.current; if (!canvas) {
if (!canvas) {
onError?.();
return;
}
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
try {
const ctx = canvas.getContext('2d', {
alpha: true,
desynchronized: true
});
if (!ctx) {
console.error('无法获取 canvas context');
onError?.(); onError?.();
return; return;
} }
const context = ctx; canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const getRandomHSLColor = () => { try {
const hue = Math.random() * 360; const ctx = canvas.getContext("2d", {
const saturation = 90 + Math.random() * 10; alpha: true,
const lightness = mode === 'dark' desynchronized: true,
? 50 + Math.random() * 15 // 暗色模式50-65% });
: 60 + Math.random() * 15; // 亮色模式60-75%
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
const ballColor = getRandomHSLColor(); if (!ctx) {
let ballRadius = 100; console.error("无法获取 canvas context");
let x = canvas.width / 2; onError?.();
let y = canvas.height - 200; return;
let dx = 0.2; }
let dy = -0.2;
function drawBall() { const context = ctx;
context.beginPath();
context.arc(x, y, ballRadius, 0, Math.PI * 2); const getRandomHSLColor = () => {
context.fillStyle = ballColor; const hue = Math.random() * 360;
context.fill(); const saturation = 90 + Math.random() * 10;
context.closePath(); const lightness =
mode === "dark"
? 50 + Math.random() * 15 // 暗色模式50-65%
: 60 + Math.random() * 15; // 亮色模式60-75%
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;
function drawBall() {
context.beginPath();
context.arc(x, y, ballRadius, 0, Math.PI * 2);
context.fillStyle = ballColor;
context.fill();
context.closePath();
}
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;
}
animationFrameId = requestAnimationFrame(draw);
};
let animationFrameId: number;
draw();
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
} catch (error) {
console.error("Canvas 初始化失败:", error);
onError?.();
return;
} }
}, [mode, onError]);
const fps = 30; return (
const interval = 1000 / fps; <div className="fixed inset-0 -z-10 overflow-hidden">
let then = Date.now(); <canvas
ref={canvasRef}
const draw = () => { className="w-full h-full opacity-70"
const now = Date.now(); style={{
const delta = now - then; filter: "blur(100px)",
position: "absolute",
if (delta > interval) { top: 0,
then = now - (delta % interval); left: 0,
willChange: "transform",
context.clearRect(0, 0, canvas.width, canvas.height); }}
drawBall(); />
</div>
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;
}
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-70"
style={{
filter: 'blur(100px)',
position: 'absolute',
top: 0,
left: 0,
willChange: 'transform'
}}
/>
</div>
);
});

File diff suppressed because it is too large Load Diff

View File

@ -7,18 +7,18 @@ export const Echoes: React.FC = () => {
useEffect(() => { useEffect(() => {
// 优化动画性能 // 优化动画性能
if (svgRef.current) { if (svgRef.current) {
svgRef.current.style.willChange = 'transform'; svgRef.current.style.willChange = "transform";
// 使用 requestAnimationFrame 来优化动画 // 使用 requestAnimationFrame 来优化动画
const paths = svgRef.current.querySelectorAll('path'); const paths = svgRef.current.querySelectorAll("path");
paths.forEach(path => { paths.forEach((path) => {
path.style.willChange = 'transform'; path.style.willChange = "transform";
}); });
} }
return () => { return () => {
if (svgRef.current) { if (svgRef.current) {
svgRef.current.style.willChange = 'auto'; svgRef.current.style.willChange = "auto";
} }
}; };
}, []); }, []);
@ -31,8 +31,8 @@ export const Echoes: React.FC = () => {
viewBox="50.4 44.600006 234.1 86" viewBox="50.4 44.600006 234.1 86"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
style={{ style={{
transform: 'translateZ(0)', // 启用硬件加速 transform: "translateZ(0)", // 启用硬件加速
backfaceVisibility: 'hidden' backfaceVisibility: "hidden",
}} }}
> >
<path <path

View File

@ -37,7 +37,7 @@ export const ThemeScript = () => {
// 客户端专用的 hook // 客户端专用的 hook
const useClientOnly = (callback: () => void, deps: any[] = []) => { const useClientOnly = (callback: () => void, deps: any[] = []) => {
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
callback(); callback();
} }
}, deps); }, deps);
@ -46,15 +46,15 @@ const useClientOnly = (callback: () => void, deps: any[] = []) => {
export const ThemeModeToggle: React.FC = () => { export const ThemeModeToggle: React.FC = () => {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [theme, setTheme] = useState<string>(() => { const [theme, setTheme] = useState<string>(() => {
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
return document.documentElement.dataset.theme || 'light'; return document.documentElement.dataset.theme || "light";
} }
return 'light'; return "light";
}); });
useClientOnly(() => { useClientOnly(() => {
const currentTheme = document.documentElement.dataset.theme || 'light'; const currentTheme = document.documentElement.dataset.theme || "light";
document.documentElement.classList.remove('light', 'dark'); document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(currentTheme); document.documentElement.classList.add(currentTheme);
setTheme(currentTheme); setTheme(currentTheme);
setMounted(true); setMounted(true);
@ -63,28 +63,29 @@ export const ThemeModeToggle: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!mounted) return; if (!mounted) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleSystemThemeChange = (e: MediaQueryListEvent) => { const handleSystemThemeChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem(THEME_KEY)) { if (!localStorage.getItem(THEME_KEY)) {
const newTheme = e.matches ? 'dark' : 'light'; const newTheme = e.matches ? "dark" : "light";
updateTheme(newTheme); updateTheme(newTheme);
} }
}; };
mediaQuery.addEventListener('change', handleSystemThemeChange); mediaQuery.addEventListener("change", handleSystemThemeChange);
return () => mediaQuery.removeEventListener('change', handleSystemThemeChange); return () =>
mediaQuery.removeEventListener("change", handleSystemThemeChange);
}, [mounted]); }, [mounted]);
const updateTheme = (newTheme: string) => { const updateTheme = (newTheme: string) => {
document.documentElement.dataset.theme = newTheme; document.documentElement.dataset.theme = newTheme;
document.documentElement.classList.remove('light', 'dark'); document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(newTheme); document.documentElement.classList.add(newTheme);
setTheme(newTheme); setTheme(newTheme);
}; };
const toggleTheme = () => { const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark'; const newTheme = theme === "dark" ? "light" : "dark";
updateTheme(newTheme); updateTheme(newTheme);
localStorage.setItem(THEME_KEY, newTheme); localStorage.setItem(THEME_KEY, newTheme);
}; };
@ -108,7 +109,7 @@ export const ThemeModeToggle: React.FC = () => {
className="w-full h-full p-0 rounded-lg transition-all duration-300 transform" className="w-full h-full p-0 rounded-lg transition-all duration-300 transform"
aria-label="Toggle theme" aria-label="Toggle theme"
> >
{theme === 'dark' ? ( {theme === "dark" ? (
<SunIcon className="w-full h-full" /> <SunIcon className="w-full h-full" />
) : ( ) : (
<MoonIcon className="w-full h-full" /> <MoonIcon className="w-full h-full" />
@ -123,7 +124,9 @@ export const useThemeMode = () => {
useClientOnly(() => { useClientOnly(() => {
const handleThemeChange = () => { const handleThemeChange = () => {
const currentTheme = document.documentElement.dataset.theme as "light" | "dark"; const currentTheme = document.documentElement.dataset.theme as
| "light"
| "dark";
setMode(currentTheme || "light"); setMode(currentTheme || "light");
}; };
@ -132,7 +135,7 @@ export const useThemeMode = () => {
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-theme') { if (mutation.attributeName === "data-theme") {
handleThemeChange(); handleThemeChange();
} }
}); });
@ -140,7 +143,7 @@ export const useThemeMode = () => {
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
attributes: true, attributes: true,
attributeFilter: ['data-theme'] attributeFilter: ["data-theme"],
}); });
return () => observer.disconnect(); return () => observer.disconnect();

View File

@ -52,7 +52,7 @@ export interface Setting {
export interface Metadata { export interface Metadata {
id: number; id: number;
targetType: 'post' | 'page'; targetType: "post" | "page";
targetId: number; targetId: number;
metaKey: string; metaKey: string;
metaValue?: string; metaValue?: string;
@ -60,7 +60,7 @@ export interface Metadata {
export interface CustomField { export interface CustomField {
id: number; id: number;
targetType: 'post' | 'page'; targetType: "post" | "page";
targetId: number; targetId: number;
fieldKey: string; fieldKey: string;
fieldValue?: string; fieldValue?: string;
@ -70,7 +70,7 @@ export interface CustomField {
export interface Taxonomy { export interface Taxonomy {
name: string; name: string;
slug: string; slug: string;
type: 'tag' | 'category'; type: "tag" | "category";
parentId?: string; parentId?: string;
} }
@ -92,4 +92,4 @@ export interface PostDisplay extends Post {
export interface PageDisplay extends Page { export interface PageDisplay extends Page {
metadata?: Metadata[]; metadata?: Metadata[];
customFields?: CustomField[]; customFields?: CustomField[];
} }

View File

@ -1,17 +1,19 @@
import { HttpClient } from "core/http"; import { HttpClient } from "core/http";
import { CapabilityService } from "core/capability"; import { CapabilityService } from "core/capability";
import { Serializable } from "interface/serializableType"; import { Serializable } from "interface/serializableType";
import { createElement, memo } from 'react'; import { createElement, memo } from "react";
export class Layout { export class Layout {
private http: HttpClient; private http: HttpClient;
private capability: CapabilityService; private capability: CapabilityService;
private readonly MemoizedElement: React.MemoExoticComponent<(props: { private readonly MemoizedElement: React.MemoExoticComponent<
children: React.ReactNode; (props: {
args?: Serializable; children: React.ReactNode;
onTouchStart?: (e: TouchEvent) => void; args?: Serializable;
onTouchEnd?: (e: TouchEvent) => void; onTouchStart?: (e: TouchEvent) => void;
}) => React.ReactNode>; onTouchEnd?: (e: TouchEvent) => void;
}) => React.ReactNode
>;
constructor( constructor(
public element: (props: { public element: (props: {
@ -30,8 +32,8 @@ export class Layout {
this.MemoizedElement = memo(element); this.MemoizedElement = memo(element);
} }
render(props: { render(props: {
children: React.ReactNode; children: React.ReactNode;
args?: Serializable; args?: Serializable;
onTouchStart?: (e: TouchEvent) => void; onTouchStart?: (e: TouchEvent) => void;
onTouchEnd?: (e: TouchEvent) => void; onTouchEnd?: (e: TouchEvent) => void;
@ -39,7 +41,7 @@ export class Layout {
return createElement(this.MemoizedElement, { return createElement(this.MemoizedElement, {
...props, ...props,
onTouchStart: props.onTouchStart, onTouchStart: props.onTouchStart,
onTouchEnd: props.onTouchEnd onTouchEnd: props.onTouchEnd,
}); });
} }
} }

View File

@ -5,10 +5,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "remix vite:build", "build": "remix vite:build",
"dev": "concurrently \"node --trace-warnings ./node_modules/vite/bin/vite.js\" \"cross-env VITE_ADDRESS=localhost VITE_PORT=22100 tsx --trace-warnings server/express.ts\"", "dev": "tsx start.ts",
"format": "prettier --write \"./**/*.{ts,tsx,js,jsx}\"", "format": "prettier --write \"./**/*.{ts,tsx,js,jsx}\"",
"lint": "eslint \"./**/*.{ts,tsx,js,jsx}\" --fix", "lint": "eslint \"./**/*.{ts,tsx,js,jsx}\" --fix",
"start": "remix-serve ./build/server/index.js", "start": "cross-env NODE_ENV=production tsx start.ts",
"typecheck": "tsc" "typecheck": "tsc"
}, },
"dependencies": { "dependencies": {

View File

@ -1,54 +1,113 @@
import { spawn } from "child_process"; import { spawn } from "child_process";
import path from "path"; import path from "path";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { DEFAULT_CONFIG } from "~/env";
// 设置全局最大监听器数量 const isDev = process.env.NODE_ENV !== "production";
// EventEmitter.defaultMaxListeners = 20;
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const startServers = async () => { const startServers = async () => {
// 先启动内部服务器 // 启动 Express 服务器(无论是开发还是生产环境都需要)
const internalServer = spawn("tsx", ["backend/internalServer.ts"], { const expressServer = spawn(
stdio: "inherit", "tsx",
shell: true, ["--trace-warnings", "server/express.ts"],
env: { {
...process.env, stdio: "inherit",
NODE_ENV: process.env.NODE_ENV || "development", shell: true,
env: {
...process.env,
PORT: DEFAULT_CONFIG.VITE_PORT + 1,
IS_API_SERVER: "true",
},
}, },
);
expressServer.on("error", (err) => {
console.error("Express 服务器启动错误:", err);
}); });
internalServer.on("error", (err) => { if (isDev) {
console.error("内部服务器启动错误:", err); // 开发环境启动
}); console.log("正在以开发模式启动服务器...");
// 等待内部服务器启动 // 启动 Vite
// console.log("等待内部服务器启动..."); const viteProcess = spawn(
// await delay(2000); "node",
["--trace-warnings", "./node_modules/vite/bin/vite.js"],
{
stdio: "inherit",
shell: true,
env: {
...process.env,
NODE_ENV: "development",
VITE_PORT: DEFAULT_CONFIG.VITE_PORT,
},
cwd: process.cwd(),
},
);
// 然后启动 Vite viteProcess.on("error", (err) => {
const viteProcess = spawn("npm", ["run", "dev"], { console.error("Vite 进程启动错误:", err);
stdio: "inherit", });
shell: true,
env: {
...process.env,
NODE_ENV: process.env.NODE_ENV || "development",
},
});
viteProcess.on("error", (err) => { const cleanup = () => {
console.error("Vite 进程启动错误:", err); console.log("正在关闭服务器...");
}); viteProcess.kill();
expressServer.kill();
process.exit();
};
const cleanup = () => { process.on("SIGINT", cleanup);
console.log("正在关闭服务器..."); process.on("SIGTERM", cleanup);
viteProcess.kill(); } else {
internalServer.kill(); // 生产环境启动
process.exit(); console.log("正在以生产模式启动服务器...");
};
process.on("SIGINT", cleanup); // 先执行构建
process.on("SIGTERM", cleanup); const buildProcess = spawn("npm", ["run", "build"], {
stdio: "inherit",
shell: true,
});
buildProcess.on("error", (err) => {
console.error("构建错误:", err);
process.exit(1);
});
buildProcess.on("close", (code) => {
if (code !== 0) {
console.error("构建失败");
process.exit(1);
}
console.log("构建完成,正在启动生产服务器...");
// 使用 remix-serve 启动生产服务器
const prodServer = spawn("remix-serve", ["./build/server/index.js"], {
stdio: "inherit",
shell: true,
env: {
...process.env,
NODE_ENV: "production",
PORT: DEFAULT_CONFIG.VITE_PORT,
},
cwd: process.cwd(),
});
prodServer.on("error", (err) => {
console.error("生产服务器启动错误:", err);
});
const cleanup = () => {
console.log("正在关闭生产服务器...");
prodServer.kill();
expressServer.kill();
process.exit();
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
});
}
}; };
startServers().catch((err) => { startServers().catch((err) => {

View File

@ -1,5 +1,5 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import typography from '@tailwindcss/typography'; import typography from "@tailwindcss/typography";
export default { export default {
content: [ content: [
@ -9,7 +9,7 @@ export default {
"./hooks/**/*.{js,jsx,ts,tsx}", "./hooks/**/*.{js,jsx,ts,tsx}",
"./themes/**/*.{js,jsx,ts,tsx}", "./themes/**/*.{js,jsx,ts,tsx}",
], ],
darkMode: 'class', darkMode: "class",
important: true, important: true,
theme: { theme: {
extend: { extend: {
@ -30,7 +30,5 @@ export default {
}, },
}, },
}, },
plugins: [ plugins: [typography],
typography,
],
} satisfies Config; } satisfies Config;

View File

@ -93,7 +93,7 @@ export default new Template({}, ({ http, args }) => {
<Flex direction="column" align="center" className="text-center mb-16"> <Flex direction="column" align="center" className="text-center mb-16">
<Box className="w-40 h-40 mb-8 relative"> <Box className="w-40 h-40 mb-8 relative">
<div className="absolute inset-0 bg-gradient-to-br from-[rgb(10,37,77)] via-[rgb(8,27,57)] to-[rgb(2,8,23)] rounded-full overflow-hidden"> <div className="absolute inset-0 bg-gradient-to-br from-[rgb(10,37,77)] via-[rgb(8,27,57)] to-[rgb(2,8,23)] rounded-full overflow-hidden">
<ImageLoader <ImageLoader
src="/images/avatar-placeholder.png" src="/images/avatar-placeholder.png"
alt="avatar" alt="avatar"
className="w-full h-full" className="w-full h-full"

View File

@ -1,7 +1,14 @@
import { Layout } from "interface/layout"; import { Layout } from "interface/layout";
import { ThemeModeToggle } from "hooks/ThemeMode"; import { ThemeModeToggle } from "hooks/ThemeMode";
import { Echoes } from "hooks/Echoes"; import { Echoes } from "hooks/Echoes";
import { Container, Flex, Box, Link, TextField, Button } from "@radix-ui/themes"; import {
Container,
Flex,
Box,
Link,
TextField,
Button,
} from "@radix-ui/themes";
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
HamburgerMenuIcon, HamburgerMenuIcon,
@ -14,12 +21,12 @@ import { useState, useEffect, useCallback } from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import throttle from "lodash/throttle"; import throttle from "lodash/throttle";
import "./styles/layouts.css"; import "./styles/layouts.css";
import parse from 'html-react-parser'; import parse from "html-react-parser";
// 直接导出 Layout 实例 // 直接导出 Layout 实例
export default new Layout(({ children, args }) => { export default new Layout(({ children, args }) => {
const [moreState, setMoreState] = useState(() => { const [moreState, setMoreState] = useState(() => {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
return window.innerWidth >= 1024 ? false : false; return window.innerWidth >= 1024 ? false : false;
} }
return false; return false;
@ -28,9 +35,9 @@ export default new Layout(({ children, args }) => {
const [scrollProgress, setScrollProgress] = useState(0); const [scrollProgress, setScrollProgress] = useState(0);
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const container = document.querySelector('#main-content'); const container = document.querySelector("#main-content");
if (!container) return; if (!container) return;
const scrollTop = container.scrollTop; const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight; const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight; const clientHeight = container.clientHeight;
@ -39,11 +46,11 @@ export default new Layout(({ children, args }) => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
const container = document.querySelector('#main-content'); const container = document.querySelector("#main-content");
if (container) { if (container) {
container.addEventListener('scroll', handleScroll); container.addEventListener("scroll", handleScroll);
} }
const throttledResize = throttle(() => { const throttledResize = throttle(() => {
@ -58,22 +65,25 @@ export default new Layout(({ children, args }) => {
return () => { return () => {
if (container) { if (container) {
container.removeEventListener('scroll', handleScroll); container.removeEventListener("scroll", handleScroll);
} }
window.removeEventListener("resize", throttledResize); window.removeEventListener("resize", throttledResize);
throttledResize.cancel(); throttledResize.cancel();
}; };
}, [handleScroll]); }, [handleScroll]);
const navString = typeof args === 'object' && args && 'nav' in args ? args.nav as string : ''; const navString =
typeof args === "object" && args && "nav" in args
? (args.nav as string)
: "";
// 添加回到顶部的处理函数 // 添加回到顶部的处理函数
const scrollToTop = () => { const scrollToTop = () => {
const container = document.querySelector('#main-content'); const container = document.querySelector("#main-content");
if (container) { if (container) {
container.scrollTo({ container.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: "smooth",
}); });
} }
}; };
@ -82,11 +92,9 @@ export default new Layout(({ children, args }) => {
const mobileMenu = ( const mobileMenu = (
<Box className="flex lg:hidden gap-2 items-center"> <Box className="flex lg:hidden gap-2 items-center">
{/* 添加移动端进度指示器 */} {/* 添加移动端进度指示器 */}
<Box <Box
className={`w-10 h-10 flex items-center justify-center ${ className={`w-10 h-10 flex items-center justify-center ${
scrollProgress > 0 scrollProgress > 0 ? "block" : "hidden"
? 'block'
: 'hidden'
}`} }`}
> >
<Button <Button
@ -94,19 +102,16 @@ export default new Layout(({ children, args }) => {
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]" 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} onClick={scrollToTop}
> >
<svg <svg className="w-6 h-6" viewBox="0 0 100 100">
className="w-6 h-6"
viewBox="0 0 100 100"
>
<text <text
x="50" x="50"
y="55" y="55"
className="progress-indicator font-bold transition-colors" className="progress-indicator font-bold transition-colors"
dominantBaseline="middle" dominantBaseline="middle"
textAnchor="middle" textAnchor="middle"
style={{ style={{
fontSize: '56px', fontSize: "56px",
fill: 'currentColor' fill: "currentColor",
}} }}
> >
{Math.round(scrollProgress)} {Math.round(scrollProgress)}
@ -115,7 +120,7 @@ export default new Layout(({ children, args }) => {
</Button> </Button>
</Box> </Box>
<Button <Button
className="w-10 h-10 p-0 hover:text-[--accent-9] transition-colors flex items-center justify-center group bg-transparent border-0" 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)} onClick={() => setMoreState(!moreState)}
> >
@ -128,7 +133,7 @@ export default new Layout(({ children, args }) => {
{/* 移动端菜单内容 */} {/* 移动端菜单内容 */}
{moreState && ( {moreState && (
<div <div
className="absolute top-full right-4 w-[180px] mt-2 rounded-md bg-[--gray-1] border border-[--gray-a5] shadow-lg 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 animate-in fade-in-0 zoom-in-95 slide-in-from-top-2
duration-200 z-[90]" duration-200 z-[90]"
@ -149,10 +154,7 @@ export default new Layout(({ children, args }) => {
placeholder="搜索..." placeholder="搜索..."
className="w-full [&_input]:pl-3 hover:border-[--accent-9] border transition-colors group" className="w-full [&_input]:pl-3 hover:border-[--accent-9] border transition-colors group"
> >
<TextField.Slot <TextField.Slot side="right" className="p-2">
side="right"
className="p-2"
>
<MagnifyingGlassIcon className="h-4 w-4 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" /> <MagnifyingGlassIcon className="h-4 w-4 text-[--gray-11] transition-colors group-hover:text-[--accent-9]" />
</TextField.Slot> </TextField.Slot>
</TextField.Root> </TextField.Root>
@ -163,8 +165,8 @@ export default new Layout(({ children, args }) => {
<Flex gap="3" align="center"> <Flex gap="3" align="center">
{/* 用户信息/登录按钮 - 调整为 70% 宽度 */} {/* 用户信息/登录按钮 - 调整为 70% 宽度 */}
<Box className="w-[70%]"> <Box className="w-[70%]">
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start gap-2 text-[--gray-12] hover:text-[--accent-9] hover:bg-[--gray-a3] transition-colors" className="w-full justify-start gap-2 text-[--gray-12] hover:text-[--accent-9] hover:bg-[--gray-a3] transition-colors"
> >
{loginState ? ( {loginState ? (
@ -200,10 +202,7 @@ export default new Layout(({ children, args }) => {
radius="large" radius="large"
panelBackground="solid" panelBackground="solid"
> >
<Box <Box className="h-screen flex flex-col overflow-hidden" id="nav">
className="h-screen flex flex-col overflow-hidden"
id="nav"
>
{/* 导航栏 */} {/* 导航栏 */}
<Box <Box
asChild asChild
@ -211,17 +210,10 @@ export default new Layout(({ children, args }) => {
> >
<nav> <nav>
<Container size="4"> <Container size="4">
<Flex <Flex justify="between" align="center" className="h-20 px-4">
justify="between"
align="center"
className="h-20 px-4"
>
{/* Logo 区域 */} {/* Logo 区域 */}
<Flex align="center"> <Flex align="center">
<Link <Link href="/" className="hover-text flex items-center">
href="/"
className="hover-text flex items-center"
>
<Box className="w-20 h-20 [&_path]:transition-all [&_path]:duration-200 group-hover:[&_path]:stroke-[--accent-9]"> <Box className="w-20 h-20 [&_path]:transition-all [&_path]:duration-200 group-hover:[&_path]:stroke-[--accent-9]">
<Echoes /> <Echoes />
</Box> </Box>
@ -256,8 +248,8 @@ export default new Layout(({ children, args }) => {
<Box className="flex items-center"> <Box className="flex items-center">
<DropdownMenuPrimitive.Root> <DropdownMenuPrimitive.Root>
<DropdownMenuPrimitive.Trigger asChild> <DropdownMenuPrimitive.Trigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className="w-10 h-10 p-0 text-[--gray-12] hover:text-[--accent-9] transition-colors flex items-center justify-center" className="w-10 h-10 p-0 text-[--gray-12] hover:text-[--accent-9] transition-colors flex items-center justify-center"
> >
{loginState ? ( {loginState ? (
@ -304,11 +296,9 @@ export default new Layout(({ children, args }) => {
</Box> </Box>
{/* 读进度指示器 */} {/* 读进度指示器 */}
<Box <Box
className={`w-10 h-10 flex items-center justify-center ${ className={`w-10 h-10 flex items-center justify-center ${
scrollProgress > 0 scrollProgress > 0 ? "block" : "hidden"
? 'block'
: 'hidden'
}`} }`}
> >
<Button <Button
@ -316,19 +306,16 @@ export default new Layout(({ children, args }) => {
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]" 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} onClick={scrollToTop}
> >
<svg <svg className="w-6 h-6" viewBox="0 0 100 100">
className="w-6 h-6"
viewBox="0 0 100 100"
>
<text <text
x="50" x="50"
y="55" y="55"
className="progress-indicator font-bold transition-colors" className="progress-indicator font-bold transition-colors"
dominantBaseline="middle" dominantBaseline="middle"
textAnchor="middle" textAnchor="middle"
style={{ style={{
fontSize: '56px', fontSize: "56px",
fill: 'currentColor' fill: "currentColor",
}} }}
> >
{Math.round(scrollProgress)} {Math.round(scrollProgress)}
@ -349,14 +336,8 @@ export default new Layout(({ children, args }) => {
</Box> </Box>
{/* 主要内容区域 */} {/* 主要内容区域 */}
<Box <Box id="main-content" className="flex-1 w-full overflow-auto">
id="main-content" <Container size="4" className="py-8">
className="flex-1 w-full overflow-auto"
>
<Container
size="4"
className="py-8"
>
<main>{children}</main> <main>{children}</main>
</Container> </Container>
</Box> </Box>
@ -364,4 +345,3 @@ export default new Layout(({ children, args }) => {
</Theme> </Theme>
); );
}); });

View File

@ -1,4 +1,10 @@
import React, { useMemo, useState, useCallback, useRef, useEffect } from "react"; import React, {
useMemo,
useState,
useCallback,
useRef,
useEffect,
} from "react";
import { Template } from "interface/template"; import { Template } from "interface/template";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
@ -13,16 +19,16 @@ import {
ScrollArea, ScrollArea,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { CalendarIcon, CodeIcon } from "@radix-ui/react-icons"; import { CalendarIcon, CodeIcon } from "@radix-ui/react-icons";
import type { PostDisplay } from "interface/fields"; import type { PostDisplay } from "interface/fields";
import type { MetaFunction } from "@remix-run/node"; import type { MetaFunction } from "@remix-run/node";
import { getColorScheme } from "themes/echoes/utils/colorScheme"; import { getColorScheme } from "themes/echoes/utils/colorScheme";
import MarkdownIt from 'markdown-it'; import MarkdownIt from "markdown-it";
import { ComponentPropsWithoutRef } from 'react'; import { ComponentPropsWithoutRef } from "react";
import remarkGfm from 'remark-gfm'; import remarkGfm from "remark-gfm";
import { toast } from "hooks/Notification"; import { toast } from "hooks/Notification";
import rehypeRaw from 'rehype-raw'; import rehypeRaw from "rehype-raw";
import remarkEmoji from 'remark-emoji'; import remarkEmoji from "remark-emoji";
import ReactDOMServer from 'react-dom/server'; import ReactDOMServer from "react-dom/server";
// 示例文章数据 // 示例文章数据
const mockPost: PostDisplay = { const mockPost: PostDisplay = {
@ -373,45 +379,51 @@ function greet(user: User): string {
`, `,
authorName: "Markdown 专家", authorName: "Markdown 专家",
publishedAt: new Date("2024-03-15"), publishedAt: new Date("2024-03-15"),
coverImage: "https://images.unsplash.com/photo-1499951360447-b19be8fe80f5?w=1200&h=600", coverImage:
"https://images.unsplash.com/photo-1499951360447-b19be8fe80f5?w=1200&h=600",
status: "published", status: "published",
isEditor: true, isEditor: true,
createdAt: new Date("2024-03-15"), createdAt: new Date("2024-03-15"),
updatedAt: new Date("2024-03-15"), updatedAt: new Date("2024-03-15"),
taxonomies: { taxonomies: {
categories: [{ categories: [
name: "教程", {
slug: "tutorial", name: "教程",
type: "category" slug: "tutorial",
}], type: "category",
},
],
tags: [ tags: [
{ name: "Markdown", slug: "markdown", type: "tag" }, { name: "Markdown", slug: "markdown", type: "tag" },
{ name: "排版", slug: "typography", type: "tag" }, { name: "排版", slug: "typography", type: "tag" },
{ name: "写作", slug: "writing", type: "tag" } { name: "写作", slug: "writing", type: "tag" },
] ],
}, },
metadata: [ metadata: [
{ {
id: 1, id: 1,
targetType: "post", targetType: "post",
targetId: 1, targetId: 1,
metaKey: "description", metaKey: "description",
metaValue: "从基础语法到高级排版,全面了解 Markdown 的各种用法和技巧。" metaValue: "从基础语法到高级排版,全面了解 Markdown 的各种用法和技巧。",
}, },
{ {
id: 2, id: 2,
targetType: "post", targetType: "post",
targetId: 1, targetId: 1,
metaKey: "keywords", metaKey: "keywords",
metaValue: "Markdown,基础语法,高级排版,布局设计" metaValue: "Markdown,基础语法,高级排版,布局设计",
} },
] ],
}; };
// 添 meta 函数 // 添 meta 函数
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
const description = mockPost.metadata?.find(m => m.metaKey === "description")?.metaValue || ""; const description =
const keywords = mockPost.metadata?.find(m => m.metaKey === "keywords")?.metaValue || ""; mockPost.metadata?.find((m) => m.metaKey === "description")?.metaValue ||
"";
const keywords =
mockPost.metadata?.find((m) => m.metaKey === "keywords")?.metaValue || "";
return [ return [
{ title: mockPost.title }, { title: mockPost.title },
@ -430,7 +442,6 @@ export const meta: MetaFunction = () => {
]; ];
}; };
// 添加复制能的接口 // 添加复制能的接口
interface CopyButtonProps { interface CopyButtonProps {
code: string; code: string;
@ -449,7 +460,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({ code }) => {
setCopied(false); setCopied(false);
}, 3000); }, 3000);
} catch (err) { } catch (err) {
console.error('复制失败:', err); console.error("复制失败:", err);
toast.error("复制失败", "请检查浏览器权限设置"); toast.error("复制失败", "请检查浏览器权限设置");
} }
}; };
@ -479,17 +490,17 @@ interface TocItem {
// 修改 generateSequentialId 函数的实现 // 修改 generateSequentialId 函数的实现
const generateSequentialId = (() => { const generateSequentialId = (() => {
const idMap = new Map<string, number>(); const idMap = new Map<string, number>();
return (postId: string, reset = false) => { return (postId: string, reset = false) => {
if (reset) { if (reset) {
idMap.delete(postId); idMap.delete(postId);
return ''; return "";
} }
if (!idMap.has(postId)) { if (!idMap.has(postId)) {
idMap.set(postId, 0); idMap.set(postId, 0);
} }
const counter = idMap.get(postId)!; const counter = idMap.get(postId)!;
const id = `heading-${counter}`; const id = `heading-${counter}`;
idMap.set(postId, counter + 1); idMap.set(postId, counter + 1);
@ -498,15 +509,15 @@ const generateSequentialId = (() => {
})(); })();
export default new Template({}, ({ http, args }) => { export default new Template({}, ({ http, args }) => {
const [toc, setToc] = useState<string[]>([]); const [toc, setToc] = useState<string[]>([]);
const [tocItems, setTocItems] = useState<TocItem[]>([]); const [tocItems, setTocItems] = useState<TocItem[]>([]);
const [activeId, setActiveId] = useState<string>(""); const [activeId, setActiveId] = useState<string>("");
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const [showToc, setShowToc] = useState(false); const [showToc, setShowToc] = useState(false);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const [headingIdsArrays, setHeadingIdsArrays] = useState<{[key: string]: string[]}>({}); const [headingIdsArrays, setHeadingIdsArrays] = useState<{
[key: string]: string[];
}>({});
const headingIds = useRef<string[]>([]); // 保持原有的 ref const headingIds = useRef<string[]>([]); // 保持原有的 ref
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const isClickScrolling = useRef(false); const isClickScrolling = useRef(false);
@ -516,56 +527,56 @@ export default new Template({}, ({ http, args }) => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
const md = new MarkdownIt(); const md = new MarkdownIt();
const tocArray: TocItem[] = []; const tocArray: TocItem[] = [];
// 重计数器,传入文章ID // 重计数器,传入文章ID
generateSequentialId(mockPost.id.toString(), true); generateSequentialId(mockPost.id.toString(), true);
let isInCodeBlock = false; let isInCodeBlock = false;
md.renderer.rules.fence = (tokens, idx, options, env, self) => { md.renderer.rules.fence = (tokens, idx, options, env, self) => {
isInCodeBlock = true; isInCodeBlock = true;
const result = self.renderToken(tokens, idx, options); const result = self.renderToken(tokens, idx, options);
isInCodeBlock = false; isInCodeBlock = false;
return result; return result;
}; };
md.renderer.rules.heading_open = (tokens, idx) => { md.renderer.rules.heading_open = (tokens, idx) => {
const token = tokens[idx]; const token = tokens[idx];
const level = parseInt(token.tag.slice(1)); const level = parseInt(token.tag.slice(1));
if (level <= 3 && !isInCodeBlock) { if (level <= 3 && !isInCodeBlock) {
const content = tokens[idx + 1].content; const content = tokens[idx + 1].content;
const id = generateSequentialId(mockPost.id.toString()); const id = generateSequentialId(mockPost.id.toString());
token.attrSet('id', id); token.attrSet("id", id);
tocArray.push({ tocArray.push({
id, id,
text: content, text: content,
level level,
}); });
} }
return md.renderer.renderToken(tokens, idx, md.options); return md.renderer.renderToken(tokens, idx, md.options);
}; };
md.render(mockPost.content); md.render(mockPost.content);
// 只在 ID 数组发生变化时更新 // 只在 ID 数组发生变化时更新
const newIds = tocArray.map(item => item.id); const newIds = tocArray.map((item) => item.id);
if (JSON.stringify(headingIds.current) !== JSON.stringify(newIds)) { if (JSON.stringify(headingIds.current) !== JSON.stringify(newIds)) {
headingIds.current = [...newIds]; headingIds.current = [...newIds];
setHeadingIdsArrays(prev => ({ setHeadingIdsArrays((prev) => ({
...prev, ...prev,
[mockPost.id]: [...newIds] [mockPost.id]: [...newIds],
})); }));
} }
setToc(newIds); setToc(newIds);
setTocItems(tocArray); setTocItems(tocArray);
if (tocArray.length > 0 && !activeId) { if (tocArray.length > 0 && !activeId) {
setActiveId(tocArray[0].id); setActiveId(tocArray[0].id);
} }
@ -579,62 +590,94 @@ export default new Template({}, ({ http, args }) => {
const components = useMemo(() => { const components = useMemo(() => {
return { return {
h1: ({ children, ...props }: ComponentPropsWithoutRef<'h1'>) => { h1: ({ children, ...props }: ComponentPropsWithoutRef<"h1">) => {
const headingId = headingIds.current.shift(); const headingId = headingIds.current.shift();
return ( return (
<h1 id={headingId} className="text-xl sm:text-2xl md:text-3xl lg:text-4xl font-bold mt-6 sm:mt-8 mb-3 sm:mb-4" {...props}> <h1
id={headingId}
className="text-xl sm:text-2xl md:text-3xl lg:text-4xl font-bold mt-6 sm:mt-8 mb-3 sm:mb-4"
{...props}
>
{children} {children}
</h1> </h1>
); );
}, },
h2: ({ children, ...props }: ComponentPropsWithoutRef<'h2'>) => { h2: ({ children, ...props }: ComponentPropsWithoutRef<"h2">) => {
const headingId = headingIds.current.shift(); const headingId = headingIds.current.shift();
return ( return (
<h2 id={headingId} className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-semibold mt-5 sm:mt-6 mb-2 sm:mb-3" {...props}> <h2
id={headingId}
className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-semibold mt-5 sm:mt-6 mb-2 sm:mb-3"
{...props}
>
{children} {children}
</h2> </h2>
); );
}, },
h3: ({ children, ...props }: ComponentPropsWithoutRef<'h3'>) => { h3: ({ children, ...props }: ComponentPropsWithoutRef<"h3">) => {
const headingId = headingIds.current.shift(); const headingId = headingIds.current.shift();
return ( return (
<h3 id={headingId} className="text-base sm:text-lg md:text-xl lg:text-2xl font-medium mt-4 mb-2" {...props}> <h3
id={headingId}
className="text-base sm:text-lg md:text-xl lg:text-2xl font-medium mt-4 mb-2"
{...props}
>
{children} {children}
</h3> </h3>
); );
}, },
p: ({ node, ...props }: ComponentPropsWithoutRef<'p'> & { node?: any }) => ( p: ({
<p node,
className="text-sm sm:text-base md:text-lg leading-relaxed mb-3 sm:mb-4 text-[--gray-11]" ...props
}: ComponentPropsWithoutRef<"p"> & { node?: any }) => (
<p
className="text-sm sm:text-base md:text-lg leading-relaxed mb-3 sm:mb-4 text-[--gray-11]"
{...props} {...props}
/> />
), ),
ul: ({ children, ...props }: ComponentPropsWithoutRef<'ul'>) => ( ul: ({ children, ...props }: ComponentPropsWithoutRef<"ul">) => (
<ul className="list-disc pl-4 sm:pl-6 mb-3 sm:mb-4 space-y-1.5 sm:space-y-2 text-[--gray-11]" {...props}> <ul
className="list-disc pl-4 sm:pl-6 mb-3 sm:mb-4 space-y-1.5 sm:space-y-2 text-[--gray-11]"
{...props}
>
{children} {children}
</ul> </ul>
), ),
ol: ({ children, ...props }: ComponentPropsWithoutRef<'ol'>) => ( ol: ({ children, ...props }: ComponentPropsWithoutRef<"ol">) => (
<ol className="list-decimal pl-4 sm:pl-6 mb-3 sm:mb-4 space-y-1.5 sm:space-y-2 text-[--gray-11]" {...props}> <ol
className="list-decimal pl-4 sm:pl-6 mb-3 sm:mb-4 space-y-1.5 sm:space-y-2 text-[--gray-11]"
{...props}
>
{children} {children}
</ol> </ol>
), ),
li: ({ children, ...props }: ComponentPropsWithoutRef<'li'>) => ( li: ({ children, ...props }: ComponentPropsWithoutRef<"li">) => (
<li className="text-sm sm:text-base md:text-lg leading-relaxed" {...props}> <li
className="text-sm sm:text-base md:text-lg leading-relaxed"
{...props}
>
{children} {children}
</li> </li>
), ),
blockquote: ({ children, ...props }: ComponentPropsWithoutRef<'blockquote'>) => ( blockquote: ({
<blockquote className="border-l-4 border-[--gray-6] pl-4 sm:pl-6 py-2 my-3 sm:my-4 text-[--gray-11] italic" {...props}> children,
...props
}: ComponentPropsWithoutRef<"blockquote">) => (
<blockquote
className="border-l-4 border-[--gray-6] pl-4 sm:pl-6 py-2 my-3 sm:my-4 text-[--gray-11] italic"
{...props}
>
{children} {children}
</blockquote> </blockquote>
), ),
pre: ({ children, ...props }: ComponentPropsWithoutRef<'pre'>) => { pre: ({ children, ...props }: ComponentPropsWithoutRef<"pre">) => {
const childArray = React.Children.toArray(children); const childArray = React.Children.toArray(children);
// 检查是否包含代码块 // 检查是否包含代码块
const codeElement = childArray.find( const codeElement = childArray.find(
child => React.isValidElement(child) && child.props.className?.includes('language-') (child) =>
React.isValidElement(child) &&
child.props.className?.includes("language-"),
); );
// 如果是代码块,让 code 组件处理 // 如果是代码块,让 code 组件处理
@ -643,76 +686,88 @@ export default new Template({}, ({ http, args }) => {
} }
// 获取内容 // 获取内容
let content = ''; let content = "";
if (typeof children === 'string') { if (typeof children === "string") {
content = children; content = children;
} else if (Array.isArray(children)) { } else if (Array.isArray(children)) {
content = children.map(child => { content = children
if (typeof child === 'string') return child; .map((child) => {
if (React.isValidElement(child)) { if (typeof child === "string") return child;
// 使用 renderToString 而不是 renderToStaticMarkup if (React.isValidElement(child)) {
return ReactDOMServer.renderToString(child as React.ReactElement) // 使用 renderToString 而不是 renderToStaticMarkup
// 移除 React 添加的 data 属性 return (
.replace(/\s+data-reactroot=""/g, '') ReactDOMServer.renderToString(child as React.ReactElement)
// 移除已经存在的 HTML 实体编码 // 移除 React 添加的 data 属性
.replace(/&quot;/g, '"') .replace(/\s+data-reactroot=""/g, "")
.replace(/&amp;/g, '&') // 移除已经存在的 HTML 实体编码
.replace(/&lt;/g, '<') .replace(/&quot;/g, '"')
.replace(/&gt;/g, '>') .replace(/&amp;/g, "&")
.replace(/&#39;/g, "'"); .replace(/&lt;/g, "<")
} .replace(/&gt;/g, ">")
return ''; .replace(/&#39;/g, "'")
}).join(''); );
}
return "";
})
.join("");
} else if (React.isValidElement(children)) { } else if (React.isValidElement(children)) {
content = ReactDOMServer.renderToString(children as React.ReactElement) content = ReactDOMServer.renderToString(
.replace(/\s+data-reactroot=""/g, '') children as React.ReactElement,
)
.replace(/\s+data-reactroot=""/g, "")
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&amp;/g, '&') .replace(/&amp;/g, "&")
.replace(/&lt;/g, '<') .replace(/&lt;/g, "<")
.replace(/&gt;/g, '>') .replace(/&gt;/g, ">")
.replace(/&#39;/g, "'"); .replace(/&#39;/g, "'");
} }
// 普通预格式化文本 // 普通预格式化文本
return ( return (
<pre <pre
className="my-4 p-4 rounded-lg overflow-x-auto whitespace-pre-wrap className="my-4 p-4 rounded-lg overflow-x-auto whitespace-pre-wrap
bg-[--gray-3] border border-[--gray-6] text-[--gray-12] bg-[--gray-3] border border-[--gray-6] text-[--gray-12]
text-sm leading-relaxed font-mono" text-sm leading-relaxed font-mono"
{...props} {...props}
> >
{content {content}
}
</pre> </pre>
); );
}, },
code: ({ inline, className, children, ...props }: ComponentPropsWithoutRef<'code'> & { code: ({
inline?: boolean, inline,
className?: string className,
children,
...props
}: ComponentPropsWithoutRef<"code"> & {
inline?: boolean;
className?: string;
}) => { }) => {
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || "");
const code = String(children).replace(/\n$/, ''); const code = String(children).replace(/\n$/, "");
// 如果是内联代码 // 如果是内联代码
if (!className || inline) { if (!className || inline) {
return ( return (
<code <code
className="px-2 py-1 rounded-md bg-[--gray-4] text-[--accent-11] font-medium text-[0.85em]" className="px-2 py-1 rounded-md bg-[--gray-4] text-[--accent-11] font-medium text-[0.85em]"
{...props} {...props}
> >
{children} {children}
</code> </code>
); );
} }
const language = match ? match[1] : ''; const language = match ? match[1] : "";
// 特殊处理表格语法 // 特殊处理表格语法
const isTable = code.includes('|') && code.includes('\n') && code.includes('---'); const isTable =
code.includes("|") && code.includes("\n") && code.includes("---");
return ( return (
<div className="my-4 sm:my-6"> <div className="my-4 sm:my-6">
<div className="flex justify-between items-center h-9 sm:h-10 px-4 sm:px-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] border-t border-x border-[--gray-6]
bg-[--gray-3] dark:bg-[--gray-3] bg-[--gray-3] dark:bg-[--gray-3]
rounded-t-lg" rounded-t-lg"
@ -722,7 +777,7 @@ export default new Template({}, ({ http, args }) => {
</div> </div>
<CopyButton code={code} /> <CopyButton code={code} />
</div> </div>
<div className="border border-[--gray-6] rounded-b-lg bg-white dark:bg-[--gray-1]"> <div className="border border-[--gray-6] rounded-b-lg bg-white dark:bg-[--gray-1]">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="p-4 sm:p-6"> <div className="p-4 sm:p-6">
@ -731,8 +786,8 @@ export default new Template({}, ({ http, args }) => {
<pre <pre
className="m-0 p-0 bg-transparent font-mono text-[0.9rem] leading-relaxed overflow-x-auto" className="m-0 p-0 bg-transparent font-mono text-[0.9rem] leading-relaxed overflow-x-auto"
style={{ style={{
color: 'inherit', color: "inherit",
whiteSpace: 'pre' whiteSpace: "pre",
}} }}
> >
{code} {code}
@ -743,15 +798,15 @@ export default new Template({}, ({ http, args }) => {
language={language || "text"} language={language || "text"}
style={{ style={{
...oneLight, ...oneLight,
'punctuation': { punctuation: {
color: 'var(--gray-12)' color: "var(--gray-12)",
}, },
'operator': { operator: {
color: 'var(--gray-12)' color: "var(--gray-12)",
},
symbol: {
color: "var(--gray-12)",
}, },
'symbol': {
color: 'var(--gray-12)'
}
}} }}
customStyle={{ customStyle={{
margin: 0, margin: 0,
@ -763,8 +818,8 @@ export default new Template({}, ({ http, args }) => {
codeTagProps={{ codeTagProps={{
className: "dark:text-[--gray-12]", className: "dark:text-[--gray-12]",
style: { style: {
color: "inherit" color: "inherit",
} },
}} }}
> >
{code} {code}
@ -777,7 +832,7 @@ export default new Template({}, ({ http, args }) => {
); );
}, },
// 修改表格相关组件的响应式设计 // 修改表格相关组件的响应式设计
table: ({ children, ...props }: ComponentPropsWithoutRef<'table'>) => ( table: ({ children, ...props }: ComponentPropsWithoutRef<"table">) => (
<div className="w-full my-4 sm:my-6 -mx-4 sm:mx-0 overflow-hidden"> <div className="w-full my-4 sm:my-6 -mx-4 sm:mx-0 overflow-hidden">
<div className="scroll-container overflow-x-auto"> <div className="scroll-container overflow-x-auto">
<div className="min-w-[640px] sm:min-w-0"> <div className="min-w-[640px] sm:min-w-0">
@ -790,17 +845,21 @@ export default new Template({}, ({ http, args }) => {
</div> </div>
</div> </div>
), ),
th: ({ children, style, ...props }: ComponentPropsWithoutRef<'th'> & { style?: React.CSSProperties }) => { th: ({
children,
style,
...props
}: ComponentPropsWithoutRef<"th"> & { style?: React.CSSProperties }) => {
// 获取对齐方式 // 获取对齐方式
const getAlignment = () => { const getAlignment = () => {
if (style?.textAlign === 'center') return 'text-center'; if (style?.textAlign === "center") return "text-center";
if (style?.textAlign === 'right') return 'text-right'; if (style?.textAlign === "right") return "text-right";
return 'text-left'; return "text-left";
}; };
return ( return (
<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 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] bg-[--gray-3] dark:bg-[--gray-3]
@ -813,17 +872,21 @@ export default new Template({}, ({ http, args }) => {
</th> </th>
); );
}, },
td: ({ children, style, ...props }: ComponentPropsWithoutRef<'td'> & { style?: React.CSSProperties }) => { td: ({
children,
style,
...props
}: ComponentPropsWithoutRef<"td"> & { style?: React.CSSProperties }) => {
// 获取父级 th 的对齐方式 // 获取父级 th 的对齐方式
const getAlignment = () => { const getAlignment = () => {
if (style?.textAlign === 'center') return 'text-center'; if (style?.textAlign === "center") return "text-center";
if (style?.textAlign === 'right') return 'text-right'; if (style?.textAlign === "right") return "text-right";
return 'text-left'; return "text-left";
}; };
return ( return (
<td <td
className={`px-4 sm:px-4 md:px-6 py-2 sm:py-3 md:py-4 text-xs sm:text-sm text-[--gray-11] break-words hyphens-auto className={`px-4 sm:px-4 md:px-6 py-2 sm:py-3 md:py-4 text-xs sm:text-sm text-[--gray-11] break-words hyphens-auto
[&:first-child]:font-medium [&:first-child]:text-[--gray-12] [&:first-child]:font-medium [&:first-child]:text-[--gray-12]
align-top ${getAlignment()}`} align-top ${getAlignment()}`}
@ -834,19 +897,25 @@ export default new Template({}, ({ http, args }) => {
); );
}, },
// 修改 details 组件 // 修改 details 组件
details: ({ node, ...props }: ComponentPropsWithoutRef<'details'> & { node?: any }) => ( details: ({
<details node,
...props
}: ComponentPropsWithoutRef<"details"> & { node?: any }) => (
<details
className="my-4 rounded-lg border border-[--gray-6] bg-[--gray-2] overflow-hidden className="my-4 rounded-lg border border-[--gray-6] bg-[--gray-2] overflow-hidden
marker:text-[--gray-11] [&[open]]:bg-[--gray-1] marker:text-[--gray-11] [&[open]]:bg-[--gray-1]
[&>*:not(summary)]:px-10 [&>*:not(summary)]:py-3 [&>*:not(summary)]:px-10 [&>*:not(summary)]:py-3
" "
{...props} {...props}
/> />
), ),
// 修改 summary 组件 // 修改 summary 组件
summary: ({ node, ...props }: ComponentPropsWithoutRef<'summary'> & { node?: any }) => ( summary: ({
<summary node,
...props
}: ComponentPropsWithoutRef<"summary"> & { node?: any }) => (
<summary
className="px-4 py-3 cursor-pointer hover:bg-[--gray-3] transition-colors className="px-4 py-3 cursor-pointer hover:bg-[--gray-3] transition-colors
text-[--gray-12] font-medium select-none text-[--gray-12] font-medium select-none
marker:text-[--gray-11] marker:text-[--gray-11]
@ -859,7 +928,7 @@ export default new Template({}, ({ http, args }) => {
// 修改滚动监听逻辑 // 修改滚动监听逻辑
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
let scrollTimeout: NodeJS.Timeout; let scrollTimeout: NodeJS.Timeout;
@ -873,14 +942,16 @@ export default new Template({}, ({ http, args }) => {
// 添加防抖,等待滚动结束后再更新高亮 // 添加防抖,等待滚动结束后再更新高亮
scrollTimeout = setTimeout(() => { scrollTimeout = setTimeout(() => {
const visibleEntries = entries.filter(entry => entry.isIntersecting); const visibleEntries = entries.filter(
(entry) => entry.isIntersecting,
);
if (visibleEntries.length > 0) { if (visibleEntries.length > 0) {
const visibleHeadings = visibleEntries const visibleHeadings = visibleEntries
.map(entry => ({ .map((entry) => ({
id: entry.target.id, id: entry.target.id,
top: entry.boundingClientRect.top, top: entry.boundingClientRect.top,
y: entry.intersectionRatio y: entry.intersectionRatio,
})) }))
.sort((a, b) => { .sort((a, b) => {
if (Math.abs(a.y - b.y) < 0.1) { if (Math.abs(a.y - b.y) < 0.1) {
@ -890,8 +961,8 @@ export default new Template({}, ({ http, args }) => {
}); });
const mostVisible = visibleHeadings[0]; const mostVisible = visibleHeadings[0];
setActiveId(currentActiveId => { setActiveId((currentActiveId) => {
if (mostVisible.id !== currentActiveId) { if (mostVisible.id !== currentActiveId) {
return mostVisible.id; return mostVisible.id;
} }
@ -902,9 +973,9 @@ export default new Template({}, ({ http, args }) => {
}, },
{ {
root: document.querySelector("#main-content"), root: document.querySelector("#main-content"),
rootMargin: '-10% 0px -70% 0px', rootMargin: "-10% 0px -70% 0px",
threshold: [0, 0.25, 0.5, 0.75, 1] threshold: [0, 0.25, 0.5, 0.75, 1],
} },
); );
if (isMounted) { if (isMounted) {
@ -932,7 +1003,7 @@ export default new Template({}, ({ http, args }) => {
// 修改点击处理函数 // 修改点击处理函数
const handleTocClick = useCallback((e: React.MouseEvent, itemId: string) => { const handleTocClick = useCallback((e: React.MouseEvent, itemId: string) => {
e.preventDefault(); e.preventDefault();
const element = document.getElementById(itemId); const element = document.getElementById(itemId);
const container = document.querySelector("#main-content"); const container = document.querySelector("#main-content");
const contentBox = document.querySelector(".prose"); const contentBox = document.querySelector(".prose");
@ -940,15 +1011,15 @@ export default new Template({}, ({ http, args }) => {
if (element && container && contentBox) { if (element && container && contentBox) {
// 设置点击滚动标志 // 设置点击滚动标志
isClickScrolling.current = true; isClickScrolling.current = true;
// 立即更新高亮,不等待滚动 // 立即更新高亮,不等待滚动
setActiveId(itemId); setActiveId(itemId);
// 计算滚动位置 // 计算滚动位置
const elementRect = element.getBoundingClientRect(); const elementRect = element.getBoundingClientRect();
const contentBoxRect = contentBox.getBoundingClientRect(); const contentBoxRect = contentBox.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const relativeTop = elementRect.top - contentBoxRect.top; const relativeTop = elementRect.top - contentBoxRect.top;
const contentOffset = contentBoxRect.top - containerRect.top; const contentOffset = contentBoxRect.top - containerRect.top;
const scrollDistance = container.scrollTop + relativeTop + contentOffset; const scrollDistance = container.scrollTop + relativeTop + contentOffset;
@ -958,7 +1029,7 @@ export default new Template({}, ({ http, args }) => {
top: scrollDistance, top: scrollDistance,
behavior: "smooth", behavior: "smooth",
}); });
// 延迟重置 isClickScrolling 标志 // 延迟重置 isClickScrolling 标志
// 增加延迟时间,确保滚动完全结束 // 增加延迟时间,确保滚动完全结束
const resetTimeout = setTimeout(() => { const resetTimeout = setTimeout(() => {
@ -982,15 +1053,15 @@ export default new Template({}, ({ http, args }) => {
)} )}
{isMounted && showToc && ( {isMounted && showToc && (
<div <div
className="lg:hidden fixed top-[var(--header-height)] inset-x-0 bottom-0 z-50 bg-black/50 transition-opacity duration-300" className="lg:hidden fixed top-[var(--header-height)] inset-x-0 bottom-0 z-50 bg-black/50 transition-opacity duration-300"
onClick={() => setShowToc(false)} onClick={() => setShowToc(false)}
> >
<div <div
className="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-[--gray-1] shadow-xl className="absolute right-0 top-0 bottom-0 w-72 bg-white dark:bg-[--gray-1] shadow-xl
transform transition-transform duration-300 ease-out transform transition-transform duration-300 ease-out
translate-x-0 animate-in slide-in-from-right" translate-x-0 animate-in slide-in-from-right"
onClick={e => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<ScrollArea <ScrollArea
type="hover" type="hover"
@ -1001,29 +1072,37 @@ export default new Template({}, ({ http, args }) => {
{tocItems.map((item, index) => { {tocItems.map((item, index) => {
if (item.level > 3) return null; if (item.level > 3) return null;
const isActive = activeId === item.id; const isActive = activeId === item.id;
return ( return (
<a <a
key={`${item.id}-${index}`} key={`${item.id}-${index}`}
href={`#${item.id}`} href={`#${item.id}`}
ref={node => { ref={(node) => {
// 当目录打开且是当前高亮项时,将其滚动到居中位置 // 当目录打开且是当前高亮项时,将其滚动到居中位置
if (node && isActive && showToc) { if (node && isActive && showToc) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
// 直接查找最近的滚动容器 // 直接查找最近的滚动容器
const scrollContainer = node.closest('.rt-ScrollAreaViewport'); const scrollContainer = node.closest(
".rt-ScrollAreaViewport",
);
if (scrollContainer) { if (scrollContainer) {
const containerHeight = scrollContainer.clientHeight; const containerHeight =
scrollContainer.clientHeight;
const elementTop = node.offsetTop; const elementTop = node.offsetTop;
const elementHeight = node.clientHeight; const elementHeight = node.clientHeight;
// 确保计算的滚动位置是正数 // 确保计算的滚动位置是正数
const scrollTop = Math.max(0, elementTop - (containerHeight / 2) + (elementHeight / 2)); const scrollTop = Math.max(
0,
elementTop -
containerHeight / 2 +
elementHeight / 2,
);
// 使用 scrollContainer 而不是 container // 使用 scrollContainer 而不是 container
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollTop, top: scrollTop,
behavior: 'smooth' behavior: "smooth",
}); });
} }
}); });
@ -1040,8 +1119,8 @@ export default new Template({}, ({ http, args }) => {
${ ${
item.level === 1 item.level === 1
? "text-sm font-medium" ? "text-sm font-medium"
: item.level === 2 : item.level === 2
? "text-[0.8125rem]" ? "text-[0.8125rem]"
: `text-xs ${isActive ? "text-[--accent-11]" : "text-[--gray-10]"}` : `text-xs ${isActive ? "text-[--accent-11]" : "text-[--gray-10]"}`
} }
`} `}
@ -1050,7 +1129,7 @@ export default new Template({}, ({ http, args }) => {
const element = document.getElementById(item.id); const element = document.getElementById(item.id);
if (element) { if (element) {
const yOffset = -80; const yOffset = -80;
element.scrollIntoView({ behavior: 'smooth' }); element.scrollIntoView({ behavior: "smooth" });
window.scrollBy(0, yOffset); window.scrollBy(0, yOffset);
setActiveId(item.id); setActiveId(item.id);
setShowToc(false); setShowToc(false);
@ -1077,7 +1156,8 @@ export default new Template({}, ({ http, args }) => {
} }
return ( return (
<Box className="prose dark:prose-invert max-w-none <Box
className="prose dark:prose-invert max-w-none
[&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:!m-0 [&_pre]:!border-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:!m-0 [&_pre]:!border-0
[&_.prism-code]:!bg-transparent [&_.prism-code]:!shadow-none [&_.prism-code]:!bg-transparent [&_.prism-code]:!shadow-none
[&_pre_.prism-code]:!bg-transparent [&_pre_.prism-code]:!shadow-none [&_pre_.prism-code]:!bg-transparent [&_pre_.prism-code]:!shadow-none
@ -1086,9 +1166,10 @@ export default new Template({}, ({ http, args }) => {
[&_:not(pre)>code]:![&::before]:hidden [&_:not(pre)>code]:![&::after]:hidden [&_:not(pre)>code]:![&::before]:hidden [&_:not(pre)>code]:![&::after]:hidden
[&_:not(pre)>code]:[&::before]:content-none [&_:not(pre)>code]:[&::after]:content-none [&_:not(pre)>code]:[&::before]:content-none [&_:not(pre)>code]:[&::after]:content-none
[&_:not(pre)>code]:!bg-[--gray-4] [&_:not(pre)>code]:!text-[--accent-11] [&_:not(pre)>code]:!bg-[--gray-4] [&_:not(pre)>code]:!text-[--accent-11]
"> "
>
<div ref={contentRef}> <div ref={contentRef}>
<ReactMarkdown <ReactMarkdown
components={components} components={components}
remarkPlugins={[remarkGfm, remarkEmoji]} remarkPlugins={[remarkGfm, remarkEmoji]}
rehypePlugins={[rehypeRaw]} rehypePlugins={[rehypeRaw]}
@ -1102,16 +1183,16 @@ export default new Template({}, ({ http, args }) => {
}, [mockPost.content, components, mockPost.id, headingIdsArrays]); // 添加必要的依赖 }, [mockPost.content, components, mockPost.id, headingIdsArrays]); // 添加必要的依赖
return ( return (
<Container <Container
ref={containerRef} ref={containerRef}
size={{initial: "2", sm: "3", md: "4"}} size={{ initial: "2", sm: "3", md: "4" }}
className="px-4 sm:px-6 md:px-8" className="px-4 sm:px-6 md:px-8"
> >
{isMounted && mobileMenu} {isMounted && mobileMenu}
<Flex <Flex
className="relative flex-col lg:flex-row" className="relative flex-col lg:flex-row"
gap={{initial: "4", lg: "8"}} gap={{ initial: "4", lg: "8" }}
> >
{/* 文章体 - 调整宽度计算 */} {/* 文章体 - 调整宽度计算 */}
<Box className="w-full lg:w-[calc(100%-12rem)] xl:w-[calc(100%-13rem)]"> <Box className="w-full lg:w-[calc(100%-12rem)] xl:w-[calc(100%-13rem)]">
@ -1119,21 +1200,18 @@ export default new Template({}, ({ http, args }) => {
{/* 头部 */} {/* 头部 */}
<Box className="mb-4 sm:mb-8"> <Box className="mb-4 sm:mb-8">
<Heading <Heading
size={{initial: "6", sm: "7", md: "8"}} size={{ initial: "6", sm: "7", md: "8" }}
className="mb-4 sm:mb-6 leading-tight text-[--gray-12] font-bold tracking-tight" className="mb-4 sm:mb-6 leading-tight text-[--gray-12] font-bold tracking-tight"
> >
{mockPost.title} {mockPost.title}
</Heading> </Heading>
<Flex <Flex
gap={{initial: "3", sm: "4", md: "6"}} gap={{ initial: "3", sm: "4", md: "6" }}
className="items-center text-[--gray-11] flex-wrap" className="items-center text-[--gray-11] flex-wrap"
> >
{/* 作者名字 */} {/* 作者名字 */}
<Text <Text size="2" weight="medium">
size="2"
weight="medium"
>
{mockPost.authorName} {mockPost.authorName}
</Text> </Text>
@ -1141,10 +1219,7 @@ export default new Template({}, ({ http, args }) => {
<Box className="w-px h-4 bg-[--gray-6]" /> <Box className="w-px h-4 bg-[--gray-6]" />
{/* 发布日期 */} {/* 发布日期 */}
<Flex <Flex align="center" gap="2">
align="center"
gap="2"
>
<CalendarIcon className="w-3.5 h-3.5" /> <CalendarIcon className="w-3.5 h-3.5" />
<Text size="2"> <Text size="2">
{mockPost.publishedAt?.toLocaleDateString("zh-CN", { {mockPost.publishedAt?.toLocaleDateString("zh-CN", {
@ -1258,9 +1333,9 @@ export default new Template({}, ({ http, args }) => {
} }
${item.level === 2 ? "ml-3" : item.level === 3 ? "ml-6" : ""} ${item.level === 2 ? "ml-3" : item.level === 3 ? "ml-6" : ""}
${ ${
item.level === 2 item.level === 2
? "text-[0.75rem]" ? "text-[0.75rem]"
: item.level === 3 : item.level === 3
? `text-[0.7rem] ${activeId === item.id ? "text-[--accent-11]" : "text-[--gray-10]"}` ? `text-[0.7rem] ${activeId === item.id ? "text-[--accent-11]" : "text-[--gray-10]"}`
: "" : ""
} }

View File

@ -1,11 +1,19 @@
import { Template } from "interface/template"; import { Template } from "interface/template";
import { Container, Heading, Text, Flex, Card, Button, ScrollArea } from "@radix-ui/themes"; import {
Container,
Heading,
Text,
Flex,
Card,
Button,
ScrollArea,
} from "@radix-ui/themes";
import { import {
CalendarIcon, CalendarIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { PostDisplay } from "interface/fields"; import { PostDisplay } from "interface/fields";
import { useMemo } from "react"; import { useMemo } from "react";
import { ImageLoader } from "hooks/ParticleImage"; import { ImageLoader } from "hooks/ParticleImage";
@ -25,19 +33,18 @@ const mockArticles: PostDisplay[] = [
createdAt: new Date("2024-03-15"), createdAt: new Date("2024-03-15"),
updatedAt: new Date("2024-03-15"), updatedAt: new Date("2024-03-15"),
taxonomies: { taxonomies: {
categories: [ categories: [{ name: "前端开发", slug: "frontend", type: "category" }],
{ name: "前端开发", slug: "frontend", type: "category" }
],
tags: [ tags: [
{ name: "工程化", slug: "engineering", type: "tag" }, { name: "工程化", slug: "engineering", type: "tag" },
{ name: "效率提升", slug: "efficiency", type: "tag" } { name: "效率提升", slug: "efficiency", type: "tag" },
] ],
} },
}, },
{ {
id: 2, id: 2,
title: "React 18 新特性详解", title: "React 18 新特性详解",
content: "React 18 带来了许多令人兴奋的新特性,包括并发渲染、自动批处理更新...", content:
"React 18 带来了许多令人兴奋的新特性,包括并发渲染、自动批处理更新...",
authorName: "李四", authorName: "李四",
publishedAt: new Date("2024-03-14"), publishedAt: new Date("2024-03-14"),
coverImage: "", coverImage: "",
@ -46,19 +53,18 @@ const mockArticles: PostDisplay[] = [
createdAt: new Date("2024-03-14"), createdAt: new Date("2024-03-14"),
updatedAt: new Date("2024-03-14"), updatedAt: new Date("2024-03-14"),
taxonomies: { taxonomies: {
categories: [ categories: [{ name: "前端开发", slug: "frontend", type: "category" }],
{ name: "前端开发", slug: "frontend", type: "category" }
],
tags: [ tags: [
{ name: "React", slug: "react", type: "tag" }, { name: "React", slug: "react", type: "tag" },
{ name: "JavaScript", slug: "javascript", type: "tag" } { name: "JavaScript", slug: "javascript", type: "tag" },
] ],
} },
}, },
{ {
id: 3, id: 3,
title: "JavaScript 性能优化技巧", title: "JavaScript 性能优化技巧",
content: "在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...", content:
"在这篇文章中,我们将探讨一些提高 JavaScript 性能的技巧和最佳实践...",
authorName: "王五", authorName: "王五",
publishedAt: new Date("2024-03-13"), publishedAt: new Date("2024-03-13"),
coverImage: "ssssxx", coverImage: "ssssxx",
@ -68,13 +74,17 @@ const mockArticles: PostDisplay[] = [
updatedAt: new Date("2024-03-13"), updatedAt: new Date("2024-03-13"),
taxonomies: { taxonomies: {
categories: [ categories: [
{ name: "性能优化", slug: "performance-optimization", type: "category" } {
name: "性能优化",
slug: "performance-optimization",
type: "category",
},
], ],
tags: [ tags: [
{ name: "JavaScript", slug: "javascript", type: "tag" }, { name: "JavaScript", slug: "javascript", type: "tag" },
{ name: "性能", slug: "performance", type: "tag" } { name: "性能", slug: "performance", type: "tag" },
] ],
} },
}, },
{ {
id: 4, id: 4,
@ -82,28 +92,31 @@ const mockArticles: PostDisplay[] = [
content: "移动端开发中的各种适配问题及解决方案...", content: "移动端开发中的各种适配问题及解决方案...",
authorName: "田六", authorName: "田六",
publishedAt: new Date("2024-03-13"), publishedAt: new Date("2024-03-13"),
coverImage: "https://images.unsplash.com/photo-1537432376769-00f5c2f4c8d2?w=500&auto=format", coverImage:
"https://images.unsplash.com/photo-1537432376769-00f5c2f4c8d2?w=500&auto=format",
status: "published", status: "published",
isEditor: false, isEditor: false,
createdAt: new Date("2024-03-13"), createdAt: new Date("2024-03-13"),
updatedAt: new Date("2024-03-13"), updatedAt: new Date("2024-03-13"),
taxonomies: { taxonomies: {
categories: [ categories: [
{ name: "移动开发", slug: "mobile-development", type: "category" } { name: "移动开发", slug: "mobile-development", type: "category" },
], ],
tags: [ tags: [
{ name: "移动端", slug: "mobile", type: "tag" }, { name: "移动端", slug: "mobile", type: "tag" },
{ name: "响应式", slug: "responsive", type: "tag" } { name: "响应式", slug: "responsive", type: "tag" },
] ],
} },
}, },
{ {
id: 5, id: 5,
title: "全栈开发:从前端到云原生的完整指南", title: "全栈开发:从前端到云原生的完整指南",
content: "本文将深入探讨现代全栈开发的各个方面,包括前端框架选择、后端架构设计、数据库优化、微服务部署以及云原生实践...", content:
"本文将深入探讨现代全栈开发的各个方面,包括前端框架选择、后端架构设计、数据库优化、微服务部署以及云原生实践...",
authorName: "赵七", authorName: "赵七",
publishedAt: new Date("2024-03-12"), publishedAt: new Date("2024-03-12"),
coverImage: "https://images.unsplash.com/photo-1537432376769-00f5c2f4c8d2?w=500&auto=format", coverImage:
"https://images.unsplash.com/photo-1537432376769-00f5c2f4c8d2?w=500&auto=format",
status: "published", status: "published",
isEditor: false, isEditor: false,
createdAt: new Date("2024-03-12"), createdAt: new Date("2024-03-12"),
@ -114,7 +127,7 @@ const mockArticles: PostDisplay[] = [
{ name: "云原生", slug: "cloud-native", type: "category" }, { name: "云原生", slug: "cloud-native", type: "category" },
{ name: "微服务", slug: "microservices", type: "category" }, { name: "微服务", slug: "microservices", type: "category" },
{ name: "DevOps", slug: "devops", type: "category" }, { name: "DevOps", slug: "devops", type: "category" },
{ name: "系统架构", slug: "system-architecture", type: "category" } { name: "系统架构", slug: "system-architecture", type: "category" },
], ],
tags: [ tags: [
{ name: "React", slug: "react", type: "tag" }, { name: "React", slug: "react", type: "tag" },
@ -124,17 +137,19 @@ const mockArticles: PostDisplay[] = [
{ name: "MongoDB", slug: "mongodb", type: "tag" }, { name: "MongoDB", slug: "mongodb", type: "tag" },
{ name: "微服务", slug: "microservices", type: "tag" }, { name: "微服务", slug: "microservices", type: "tag" },
{ name: "CI/CD", slug: "ci-cd", type: "tag" }, { name: "CI/CD", slug: "ci-cd", type: "tag" },
{ name: "云计算", slug: "cloud-computing", type: "tag" } { name: "云计算", slug: "cloud-computing", type: "tag" },
] ],
} },
}, },
{ {
id: 6, id: 6,
title: "深入浅出 TypeScript 高级特性", title: "深入浅出 TypeScript 高级特性",
content: "探索 TypeScript 的高级类型系统、装饰器、类型编程等特性,以及在大型项目中的最佳实践...", content:
"探索 TypeScript 的高级类型系统、装饰器、类型编程等特性,以及在大型项目中的最佳实践...",
authorName: "孙八", authorName: "孙八",
publishedAt: new Date("2024-03-11"), publishedAt: new Date("2024-03-11"),
coverImage: "https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format", coverImage:
"https://images.unsplash.com/photo-1516116216624-53e697fedbea?w=500&auto=format",
status: "published", status: "published",
isEditor: false, isEditor: false,
createdAt: new Date("2024-03-11"), createdAt: new Date("2024-03-11"),
@ -142,47 +157,55 @@ const mockArticles: PostDisplay[] = [
taxonomies: { taxonomies: {
categories: [ categories: [
{ name: "TypeScript", slug: "typescript", type: "category" }, { name: "TypeScript", slug: "typescript", type: "category" },
{ name: "编程语言", slug: "programming-languages", type: "category" } { name: "编程语言", slug: "programming-languages", type: "category" },
], ],
tags: [ tags: [
{ name: "类型系统", slug: "type-system", type: "tag" }, { name: "类型系统", slug: "type-system", type: "tag" },
{ name: "泛型编程", slug: "generic-programming", type: "tag" }, { name: "泛型编程", slug: "generic-programming", type: "tag" },
{ name: "装饰器", slug: "decorators", type: "tag" }, { name: "装饰器", slug: "decorators", type: "tag" },
{ name: "类型推导", slug: "type-inference", type: "tag" } { name: "类型推导", slug: "type-inference", type: "tag" },
] ],
} },
}, },
{ {
id: 7, id: 7,
title: "Web 性能优化:从理论到实践", title: "Web 性能优化:从理论到实践",
content: "全面解析 Web 性能优化策略,包括资源加载优化、渲染性能优化、网络优化等多个...", content:
"全面解析 Web 性能优化策略,包括资源加载优化、渲染性能优化、网络优化等多个...",
authorName: "周九", authorName: "周九",
publishedAt: new Date("2024-03-10"), publishedAt: new Date("2024-03-10"),
coverImage: "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=500&auto=format", coverImage:
"https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=500&auto=format",
status: "published", status: "published",
isEditor: false, isEditor: false,
createdAt: new Date("2024-03-10"), createdAt: new Date("2024-03-10"),
updatedAt: new Date("2024-03-10"), updatedAt: new Date("2024-03-10"),
taxonomies: { taxonomies: {
categories: [ categories: [
{ name: "性能优化", slug: "performance-optimization", type: "category" }, {
{ name: "前端开发", slug: "frontend-development", type: "category" } name: "性能优化",
slug: "performance-optimization",
type: "category",
},
{ name: "前端开发", slug: "frontend-development", type: "category" },
], ],
tags: [ tags: [
{ name: "性能监控", slug: "performance-monitoring", type: "tag" }, { name: "性能监控", slug: "performance-monitoring", type: "tag" },
{ name: "懒加载", slug: "lazy-loading", type: "tag" }, { name: "懒加载", slug: "lazy-loading", type: "tag" },
{ name: "缓存策略", slug: "caching-strategies", type: "tag" }, { name: "缓存策略", slug: "caching-strategies", type: "tag" },
{ name: "代码分割", slug: "code-splitting", type: "tag" } { name: "代码分割", slug: "code-splitting", type: "tag" },
] ],
} },
}, },
{ {
id: 8, id: 8,
title: "微前端架构实践指南", title: "微前端架构实践指南",
content: "详细介绍微前端的架构设计、实现方案、应用集成以及实际项目中的经验总结...", content:
"详细介绍微前端的架构设计、实现方案、应用集成以及实际项目中的经验总结...",
authorName: "吴十", authorName: "吴十",
publishedAt: new Date("2024-03-09"), publishedAt: new Date("2024-03-09"),
coverImage: "https://images.unsplash.com/photo-1517180102446-f3ece451e9d8?w=500&auto=format", coverImage:
"https://images.unsplash.com/photo-1517180102446-f3ece451e9d8?w=500&auto=format",
status: "published", status: "published",
isEditor: false, isEditor: false,
createdAt: new Date("2024-03-09"), createdAt: new Date("2024-03-09"),
@ -190,23 +213,25 @@ const mockArticles: PostDisplay[] = [
taxonomies: { taxonomies: {
categories: [ categories: [
{ name: "架构设计", slug: "architecture-design", type: "category" }, { name: "架构设计", slug: "architecture-design", type: "category" },
{ name: "微前端", slug: "micro-frontends", type: "category" } { name: "微前端", slug: "micro-frontends", type: "category" },
], ],
tags: [ tags: [
{ name: "qiankun", slug: "qiankun", type: "tag" }, { name: "qiankun", slug: "qiankun", type: "tag" },
{ name: "single-spa", slug: "single-spa", type: "tag" }, { name: "single-spa", slug: "single-spa", type: "tag" },
{ name: "模块联邦", slug: "module-federation", type: "tag" }, { name: "模块联邦", slug: "module-federation", type: "tag" },
{ name: "应用通信", slug: "application-communication", type: "tag" } { name: "应用通信", slug: "application-communication", type: "tag" },
] ],
} },
}, },
{ {
id: 9, id: 9,
title: "AI 驱动的前端开发:从概念到实践", title: "AI 驱动的前端开发:从概念到实践",
content: "探索如何将人工智能技术融入前端开发流程包括智能代码补全、自动化测试、UI 生成、性能优化建议等实践应用...", content:
"探索如何将人工智能技术融入前端开发流程包括智能代码补全、自动化测试、UI 生成、性能优化建议等实践应用...",
authorName: "陈十一", authorName: "陈十一",
publishedAt: new Date("2024-03-08"), publishedAt: new Date("2024-03-08"),
coverImage: "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=500&auto=format", coverImage:
"https://images.unsplash.com/photo-1677442136019-21780ecad995?w=500&auto=format",
status: "published", status: "published",
isEditor: false, isEditor: false,
createdAt: new Date("2024-03-08"), createdAt: new Date("2024-03-08"),
@ -214,23 +239,23 @@ const mockArticles: PostDisplay[] = [
taxonomies: { taxonomies: {
categories: [ categories: [
{ name: "人工智能", slug: "artificial-intelligence", type: "category" }, { name: "人工智能", slug: "artificial-intelligence", type: "category" },
{ name: "前端开发", slug: "frontend-development", type: "category" } { name: "前端开发", slug: "frontend-development", type: "category" },
], ],
tags: [ tags: [
{ name: "AI开发", slug: "ai-development", type: "tag" }, { name: "AI开发", slug: "ai-development", type: "tag" },
{ name: "智能化", slug: "intelligence", type: "tag" }, { name: "智能化", slug: "intelligence", type: "tag" },
{ name: "自动化", slug: "automation", type: "tag" }, { name: "自动化", slug: "automation", type: "tag" },
{ name: "开发效率", slug: "development-efficiency", type: "tag" } { name: "开发效率", slug: "development-efficiency", type: "tag" },
] ],
} },
} },
]; ];
export default new Template({}, ({ http, args }) => { export default new Template({}, ({ http, args }) => {
const articleData = useMemo(() => mockArticles, []); const articleData = useMemo(() => mockArticles, []);
const totalPages = 25; // 假设有25页 const totalPages = 25; // 假设有25页
const currentPage = 1; // 当前页码 const currentPage = 1; // 当前页码
// 修改生成分页数组的函数,不再需要省略号 // 修改生成分页数组的函数,不再需要省略号
const getPageNumbers = (total: number) => { const getPageNumbers = (total: number) => {
return Array.from({ length: total }, (_, i) => i + 1); return Array.from({ length: total }, (_, i) => i + 1);
@ -239,19 +264,20 @@ export default new Template({}, ({ http, args }) => {
// 修改分页部分的渲染 // 修改分页部分的渲染
const renderPageNumbers = () => { const renderPageNumbers = () => {
const pages = getPageNumbers(totalPages); const pages = getPageNumbers(totalPages);
return pages.map(page => ( return pages.map((page) => (
<div <div
key={page} key={page}
className={`min-w-[32px] h-8 rounded-md transition-all duration-300 cursor-pointer className={`min-w-[32px] h-8 rounded-md transition-all duration-300 cursor-pointer
flex items-center justify-center group/item whitespace-nowrap flex items-center justify-center group/item whitespace-nowrap
${page === currentPage ${
? 'bg-[--accent-9] text-[--text-primary]' page === currentPage
: 'text-[--text-secondary] hover:text-[--text-primary] hover:bg-[--accent-3]' ? "bg-[--accent-9] text-[--text-primary]"
: "text-[--text-secondary] hover:text-[--text-primary] hover:bg-[--accent-3]"
}`} }`}
> >
<Text <Text
size="1" size="1"
weight={page === currentPage ? "medium" : "regular"} weight={page === currentPage ? "medium" : "regular"}
className="group-hover/item:scale-110 transition-transform" className="group-hover/item:scale-110 transition-transform"
> >
@ -290,8 +316,10 @@ export default new Template({}, ({ http, args }) => {
{article.title} {article.title}
</Heading> </Heading>
<Text className="text-[--text-secondary] text-xs <Text
line-clamp-2 leading-relaxed"> className="text-[--text-secondary] text-xs
line-clamp-2 leading-relaxed"
>
{article.content} {article.content}
</Text> </Text>
</div> </div>
@ -299,9 +327,9 @@ export default new Template({}, ({ http, args }) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ScrollArea <ScrollArea
type="hover" type="hover"
scrollbars="horizontal" scrollbars="horizontal"
className="scroll-container flex-1" className="scroll-container flex-1"
> >
<Flex gap="2" className="flex-nowrap"> <Flex gap="2" className="flex-nowrap">
@ -322,7 +350,11 @@ export default new Template({}, ({ http, args }) => {
</Flex> </Flex>
</ScrollArea> </ScrollArea>
<Flex gap="2" align="center" className="text-[--text-tertiary] flex-shrink-0"> <Flex
gap="2"
align="center"
className="text-[--text-tertiary] flex-shrink-0"
>
<CalendarIcon className="w-4 h-4" /> <CalendarIcon className="w-4 h-4" />
<Text size="2"> <Text size="2">
{article.publishedAt?.toLocaleDateString("zh-CN", { {article.publishedAt?.toLocaleDateString("zh-CN", {
@ -348,11 +380,11 @@ export default new Template({}, ({ http, args }) => {
${getColorScheme(tag.name).bg} ${getColorScheme(tag.name).text} ${getColorScheme(tag.name).bg} ${getColorScheme(tag.name).text}
border ${getColorScheme(tag.name).border} ${getColorScheme(tag.name).hover}`} border ${getColorScheme(tag.name).border} ${getColorScheme(tag.name).hover}`}
> >
<span <span
className={`inline-block w-1 h-1 rounded-full ${getColorScheme(tag.name).dot}`} className={`inline-block w-1 h-1 rounded-full ${getColorScheme(tag.name).dot}`}
style={{ style={{
flexShrink: 0, flexShrink: 0,
opacity: 0.8 opacity: 0.8,
}} }}
/> />
{tag.name} {tag.name}
@ -366,13 +398,13 @@ export default new Template({}, ({ http, args }) => {
</div> </div>
<div className="px-4 md:px-0"> <div className="px-4 md:px-0">
<Flex <Flex
align="center" align="center"
justify="between" justify="between"
className="max-w-[800px] mx-auto" className="max-w-[800px] mx-auto"
> >
<Button <Button
variant="ghost" variant="ghost"
className="group/nav h-8 md:px-3 text-sm hidden md:flex" className="group/nav h-8 md:px-3 text-sm hidden md:flex"
disabled={true} disabled={true}
> >
@ -380,18 +412,22 @@ export default new Template({}, ({ http, args }) => {
<span className="hidden md:inline"></span> <span className="hidden md:inline"></span>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
className="group/nav w-8 h-8 md:hidden" className="group/nav w-8 h-8 md:hidden"
disabled={true} disabled={true}
> >
<ChevronLeftIcon className="w-4 h-4 text-[--text-tertiary]" /> <ChevronLeftIcon className="w-4 h-4 text-[--text-tertiary]" />
</Button> </Button>
<Flex align="center" gap="2" className="flex-1 md:flex-none justify-center"> <Flex
<ScrollArea align="center"
type="hover" gap="2"
scrollbars="horizontal" className="flex-1 md:flex-none justify-center"
>
<ScrollArea
type="hover"
scrollbars="horizontal"
className="w-[240px] md:w-[400px]" className="w-[240px] md:w-[400px]"
> >
<Flex gap="1" className="px-2"> <Flex gap="1" className="px-2">
@ -399,23 +435,23 @@ export default new Template({}, ({ http, args }) => {
</Flex> </Flex>
</ScrollArea> </ScrollArea>
<Text size="1" className="text-[--text-tertiary] whitespace-nowrap hidden md:block"> <Text
size="1"
className="text-[--text-tertiary] whitespace-nowrap hidden md:block"
>
{totalPages} {totalPages}
</Text> </Text>
</Flex> </Flex>
<Button <Button
variant="ghost" variant="ghost"
className="group/nav h-8 md:px-3 text-sm hidden md:flex" className="group/nav h-8 md:px-3 text-sm hidden md:flex"
> >
<span className="hidden md:inline"></span> <span className="hidden md:inline"></span>
<ChevronRightIcon className="w-4 h-4 md:ml-1 text-[--text-tertiary] group-hover/nav:translate-x-0.5 transition-transform" /> <ChevronRightIcon className="w-4 h-4 md:ml-1 text-[--text-tertiary] group-hover/nav:translate-x-0.5 transition-transform" />
</Button> </Button>
<Button <Button variant="ghost" className="group/nav w-8 h-8 md:hidden">
variant="ghost"
className="group/nav w-8 h-8 md:hidden"
>
<ChevronRightIcon className="w-4 h-4 text-[--text-tertiary]" /> <ChevronRightIcon className="w-4 h-4 text-[--text-tertiary]" />
</Button> </Button>
</Flex> </Flex>

View File

@ -3,7 +3,7 @@ export function hashString(str: string): number {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i); const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char; hash = (hash << 5) - hash + char;
hash = hash & hash; hash = hash & hash;
} }
return Math.abs(hash); return Math.abs(hash);
@ -11,26 +11,76 @@ export function hashString(str: string): number {
export function getColorScheme(name: string) { export function getColorScheme(name: string) {
const colorSchemes = [ const colorSchemes = [
{ name: 'amber', bg: 'bg-[--amber-a3]', text: 'text-[--amber-11]', border: 'border-[--amber-a6]' }, {
{ name: 'blue', bg: 'bg-[--blue-a3]', text: 'text-[--blue-11]', border: 'border-[--blue-a6]' }, name: "amber",
{ name: 'crimson', bg: 'bg-[--crimson-a3]', text: 'text-[--crimson-11]', border: 'border-[--crimson-a6]' }, bg: "bg-[--amber-a3]",
{ name: 'cyan', bg: 'bg-[--cyan-a3]', text: 'text-[--cyan-11]', border: 'border-[--cyan-a6]' }, text: "text-[--amber-11]",
{ name: 'grass', bg: 'bg-[--grass-a3]', text: 'text-[--grass-11]', border: 'border-[--grass-a6]' }, border: "border-[--amber-a6]",
{ name: 'mint', bg: 'bg-[--mint-a3]', text: 'text-[--mint-11]', border: 'border-[--mint-a6]' }, },
{ name: 'orange', bg: 'bg-[--orange-a3]', text: 'text-[--orange-11]', border: 'border-[--orange-a6]' }, {
{ name: 'pink', bg: 'bg-[--pink-a3]', text: 'text-[--pink-11]', border: 'border-[--pink-a6]' }, name: "blue",
{ name: 'plum', bg: 'bg-[--plum-a3]', text: 'text-[--plum-11]', border: 'border-[--plum-a6]' }, bg: "bg-[--blue-a3]",
{ name: 'violet', bg: 'bg-[--violet-a3]', text: 'text-[--violet-11]', border: 'border-[--violet-a6]' } text: "text-[--blue-11]",
border: "border-[--blue-a6]",
},
{
name: "crimson",
bg: "bg-[--crimson-a3]",
text: "text-[--crimson-11]",
border: "border-[--crimson-a6]",
},
{
name: "cyan",
bg: "bg-[--cyan-a3]",
text: "text-[--cyan-11]",
border: "border-[--cyan-a6]",
},
{
name: "grass",
bg: "bg-[--grass-a3]",
text: "text-[--grass-11]",
border: "border-[--grass-a6]",
},
{
name: "mint",
bg: "bg-[--mint-a3]",
text: "text-[--mint-11]",
border: "border-[--mint-a6]",
},
{
name: "orange",
bg: "bg-[--orange-a3]",
text: "text-[--orange-11]",
border: "border-[--orange-a6]",
},
{
name: "pink",
bg: "bg-[--pink-a3]",
text: "text-[--pink-11]",
border: "border-[--pink-a6]",
},
{
name: "plum",
bg: "bg-[--plum-a3]",
text: "text-[--plum-11]",
border: "border-[--plum-a6]",
},
{
name: "violet",
bg: "bg-[--violet-a3]",
text: "text-[--violet-11]",
border: "border-[--violet-a6]",
},
]; ];
const index = hashString(name) % colorSchemes.length; const index = hashString(name) % colorSchemes.length;
const scheme = colorSchemes[index]; const scheme = colorSchemes[index];
return { return {
bg: scheme.bg, bg: scheme.bg,
text: scheme.text, text: scheme.text,
border: scheme.border, border: scheme.border,
hover: `hover:${scheme.bg.replace('3', '4')}`, hover: `hover:${scheme.bg.replace("3", "4")}`,
dot: `bg-current` dot: `bg-current`,
}; };
} }

View File

@ -30,89 +30,84 @@ const createDefineConfig = (config: EnvConfig) => {
); );
}; };
export default defineConfig(async ({ mode }: ConfigEnv): Promise<UserConfig> => { export default defineConfig(
// 确保每次都读取最新的环境变量 async ({ mode }: ConfigEnv): Promise<UserConfig> => {
const currentConfig = await getLatestEnv(); // 确保每次都读取最新的环境变量
const env = loadEnv(mode, process.cwd(), "VITE_"); const currentConfig = await getLatestEnv();
const env = loadEnv(mode, process.cwd(), "VITE_");
return { return {
plugins: [ plugins: [
remix({ remix({
future: { future: {
v3_fetcherPersist: true, v3_fetcherPersist: true,
v3_relativeSplatPath: true, v3_relativeSplatPath: true,
v3_throwAbortReason: true, v3_throwAbortReason: true,
v3_singleFetch: true, v3_singleFetch: true,
v3_lazyRouteDiscovery: true, v3_lazyRouteDiscovery: true,
}, },
routes: async (defineRoutes) => { routes: async (defineRoutes) => {
// 每次路由配置时重新读取环境变量 // 每次路由配置时重新读取环境变量
const latestConfig = await getLatestEnv(); const latestConfig = await getLatestEnv();
return defineRoutes((route) => { return defineRoutes((route) => {
if (Number(latestConfig.VITE_INIT_STATUS) < 3) { if (Number(latestConfig.VITE_INIT_STATUS) < 3) {
route("/", "init.tsx", { id: "index-route" }); route("/", "init.tsx", { id: "index-route" });
route("*", "init.tsx", { id: "catch-all-route" }); route("*", "init.tsx", { id: "catch-all-route" });
} else { } else {
route("/", "routes.tsx", { id: "index-route" }); route("/", "routes.tsx", { id: "index-route" });
route("*", "routes.tsx", { id: "catch-all-route" }); route("*", "routes.tsx", { id: "catch-all-route" });
} }
}); });
}, },
}), }),
tsconfigPaths(), tsconfigPaths(),
], ],
define: createDefineConfig(currentConfig), define: createDefineConfig(currentConfig),
server: { server: {
host: currentConfig.VITE_ADDRESS, host: currentConfig.VITE_ADDRESS,
port: Number(env.VITE_SYSTEM_PORT ?? currentConfig.VITE_PORT), port: Number(env.VITE_SYSTEM_PORT ?? currentConfig.VITE_PORT),
strictPort: true, strictPort: true,
hmr: true, hmr: true,
watch: { watch: {
usePolling: true, usePolling: true,
},
proxy: {
"/__/api": {
target: currentConfig.VITE_API_BASE_URL,
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/__\/api/, ""),
},
"/__/express": {
target: `http://${currentConfig.VITE_ADDRESS}:${Number(currentConfig.VITE_PORT) + 1}`,
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/__\/express/, ""),
}, },
}, },
}, publicDir: resolve(__dirname, "public"),
publicDir: resolve(__dirname, "public"), envPrefix: "VITE_",
envPrefix: "VITE_", build: {
build: { rollupOptions: {
rollupOptions: { output: {
output: { manualChunks(id) {
manualChunks(id) { // 根据模块路径进行代码分割
// 根据模块路径进行代码分割 if (id.includes("node_modules")) {
if (id.includes('node_modules')) { return "vendor";
return 'vendor'; }
} },
} },
} },
chunkSizeWarningLimit: 1500,
minify: "terser",
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
cssMinify: true,
cssCodeSplit: true,
sourcemap: mode !== "production",
assetsInlineLimit: 4096,
reportCompressedSize: false,
}, },
chunkSizeWarningLimit: 1500, ssr: {
minify: 'terser', noExternal: [
terserOptions: { "three",
compress: { "@react-three/fiber",
drop_console: true, "@react-three/drei",
drop_debugger: true "gsap",
} ],
}, },
cssMinify: true, };
cssCodeSplit: true, },
sourcemap: mode !== 'production', );
assetsInlineLimit: 4096,
reportCompressedSize: false,
},
ssr: {
noExternal: ['three', '@react-three/fiber', '@react-three/drei', 'gsap']
}
};
});