feat: 更新评论和帖子模型,添加用户信息和原创标识,优化API请求和组件结构

This commit is contained in:
2025-07-28 06:22:07 +08:00
parent 5c20a310e3
commit d73ed493be
20 changed files with 260 additions and 181 deletions

View File

@ -102,13 +102,12 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
resps.BadRequest(c, "无效的 target_id") resps.BadRequest(c, "无效的 target_id")
return return
} }
req := dto.GetCommentListReq{ req := dto.GetCommentListReq{
Desc: pagination.Desc, Desc: pagination.Desc,
OrderBy: pagination.OrderBy, OrderBy: pagination.OrderBy,
Page: pagination.Page, Page: pagination.Page,
Size: pagination.Size, Size: pagination.Size,
TargetID: uint(targetID), TargetID: uint(targetID),
TargetType: c.Query("target_type"), TargetType: c.Query("target_type"),
} }
resp, err := cc.service.GetCommentList(ctx, &req) resp, err := cc.service.GetCommentList(ctx, &req)
@ -120,4 +119,6 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
resps.Ok(c, resps.Success, resp) resps.Ok(c, resps.Success, resp)
} }
func (cc *CommentController) ReactComment(ctx context.Context, c *app.RequestContext) {} func (cc *CommentController) ReactComment(ctx context.Context, c *app.RequestContext) {
}

View File

@ -2,7 +2,6 @@ package v1
import ( import (
"context" "context"
"fmt"
"github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/common/utils" "github.com/cloudwego/hertz/pkg/common/utils"
"github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/ctxutils"
@ -95,7 +94,6 @@ func (p *PostController) Update(ctx context.Context, c *app.RequestContext) {
func (p *PostController) List(ctx context.Context, c *app.RequestContext) { func (p *PostController) List(ctx context.Context, c *app.RequestContext) {
pagination := ctxutils.GetPaginationParams(c) pagination := ctxutils.GetPaginationParams(c)
fmt.Println(pagination)
if pagination.OrderBy == "" { if pagination.OrderBy == "" {
pagination.OrderBy = constant.OrderByUpdatedAt pagination.OrderBy = constant.OrderByUpdatedAt
} }

View File

@ -5,11 +5,13 @@ import "time"
type PostDto struct { type PostDto struct {
ID uint `json:"id"` // 帖子ID ID uint `json:"id"` // 帖子ID
UserID uint `json:"user_id"` // 发布者的用户ID UserID uint `json:"user_id"` // 发布者的用户ID
User UserDto `json:"user"` // 发布者信息
Title string `json:"title"` // 帖子标题 Title string `json:"title"` // 帖子标题
Content string `json:"content"` Content string `json:"content"`
Cover string `json:"cover"` // 帖子封面图 Cover string `json:"cover"` // 帖子封面图
Type string `json:"type"` // 帖子类型 markdown / html / text Type string `json:"type"` // 帖子类型 markdown / html / text
Labels []LabelDto `json:"labels"` // 关联的标签 Labels []LabelDto `json:"labels"` // 关联的标签
IsOriginal bool `json:"is_original"` // 是否为原创帖子
IsPrivate bool `json:"is_private"` // 是否为私密帖子 IsPrivate bool `json:"is_private"` // 是否为私密帖子
LikeCount uint64 `json:"like_count"` // 点赞数 LikeCount uint64 `json:"like_count"` // 点赞数
CommentCount uint64 `json:"comment_count"` // 评论数 CommentCount uint64 `json:"comment_count"` // 评论数

View File

@ -23,9 +23,9 @@ type UserLoginReq struct {
} }
type UserLoginResp struct { type UserLoginResp struct {
Token string `json:"token"` Token string `json:"token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
User *UserDto `json:"user"` User UserDto `json:"user"`
} }
type UserRegisterReq struct { type UserRegisterReq struct {
@ -37,9 +37,9 @@ type UserRegisterReq struct {
} }
type UserRegisterResp struct { type UserRegisterResp struct {
Token string `json:"token"` // 访问令牌 Token string `json:"token"` // 访问令牌
RefreshToken string `json:"refresh_token"` // 刷新令牌 RefreshToken string `json:"refresh_token"` // 刷新令牌
User *UserDto `json:"user"` // 用户信息 User UserDto `json:"user"` // 用户信息
} }
type VerifyEmailReq struct { type VerifyEmailReq struct {
@ -57,9 +57,9 @@ type OidcLoginReq struct {
} }
type OidcLoginResp struct { type OidcLoginResp struct {
Token string `json:"token"` Token string `json:"token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
User *UserDto `json:"user"` User UserDto `json:"user"`
} }
type ListOidcConfigResp struct { type ListOidcConfigResp struct {
@ -71,7 +71,7 @@ type GetUserReq struct {
} }
type GetUserResp struct { type GetUserResp struct {
User *UserDto `json:"user"` // 用户信息 User UserDto `json:"user"` // 用户信息
} }
type UpdateUserReq struct { type UpdateUserReq struct {

View File

@ -1,6 +1,7 @@
package model package model
import ( import (
"fmt"
"github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/constant"
"gorm.io/gorm" "gorm.io/gorm"
@ -13,10 +14,11 @@ type Post struct {
Title string `gorm:"type:text;not null"` // 帖子标题 Title string `gorm:"type:text;not null"` // 帖子标题
Cover string `gorm:"type:text"` // 帖子封面图 Cover string `gorm:"type:text"` // 帖子封面图
Content string `gorm:"type:text;not null"` // 帖子内容 Content string `gorm:"type:text;not null"` // 帖子内容
Type string `gorm:"type:text;default:markdown"` // markdown类型支持markdown或html Type string `gorm:"type:text;default:markdown"` // markdown类型支持markdown或html或txt
CategoryID uint `gorm:"index"` // 帖子分类ID CategoryID uint `gorm:"index"` // 帖子分类ID
Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类 Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签 Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
IsOriginal bool `gorm:"default:true"` // 是否为原创帖子
IsPrivate bool `gorm:"default:false"` IsPrivate bool `gorm:"default:false"`
LikeCount uint64 LikeCount uint64
CommentCount uint64 CommentCount uint64
@ -44,26 +46,36 @@ func (p *Post) AfterUpdate(tx *gorm.DB) (err error) {
return nil return nil
} }
func (p *Post) ToDto() dto.PostDto { func (p *Post) ToDto() *dto.PostDto {
return dto.PostDto{ fmt.Println("User", p.User)
ID: p.ID, return &dto.PostDto{
UserID: p.UserID, ID: p.ID,
Title: p.Title, UserID: p.UserID,
Content: p.Content, Title: p.Title,
Cover: p.Cover, Content: p.Content,
Type: p.Type, Cover: p.Cover,
IsPrivate: p.IsPrivate, Type: p.Type,
IsOriginal: p.IsOriginal,
IsPrivate: p.IsPrivate,
Labels: func() []dto.LabelDto {
labelDtos := make([]dto.LabelDto, len(p.Labels))
for i, label := range p.Labels {
labelDtos[i] = label.ToDto()
}
return labelDtos
}(),
LikeCount: p.LikeCount, LikeCount: p.LikeCount,
CommentCount: p.CommentCount, CommentCount: p.CommentCount,
ViewCount: p.ViewCount, ViewCount: p.ViewCount,
Heat: p.Heat, Heat: p.Heat,
CreatedAt: p.CreatedAt, CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt, UpdatedAt: p.UpdatedAt,
User: p.User.ToDto(),
} }
} }
// ToDtoWithShortContent 返回一个简化的 DTO内容可以根据需要截断 // ToDtoWithShortContent 返回一个简化的 DTO内容可以根据需要截断
func (p *Post) ToDtoWithShortContent(contentLength int) dto.PostDto { func (p *Post) ToDtoWithShortContent(contentLength int) *dto.PostDto {
dtoPost := p.ToDto() dtoPost := p.ToDto()
if len(p.Content) > contentLength { if len(p.Content) > contentLength {
dtoPost.Content = p.Content[:contentLength] + "..." dtoPost.Content = p.Content[:contentLength] + "..."

View File

@ -25,8 +25,8 @@ type UserOpenID struct {
Sub string `gorm:"index"` // OIDC Sub openid Sub string `gorm:"index"` // OIDC Sub openid
} }
func (user *User) ToDto() *dto.UserDto { func (user *User) ToDto() dto.UserDto {
return &dto.UserDto{ return dto.UserDto{
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Nickname: user.Nickname, Nickname: user.Nickname,

View File

@ -31,7 +31,7 @@ func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateComm
UserID: currentUser.ID, UserID: currentUser.ID,
IsPrivate: req.IsPrivate, IsPrivate: req.IsPrivate,
} }
err := repo.Comment.CreateComment(comment) err := repo.Comment.CreateComment(comment)
if err != nil { if err != nil {
@ -63,7 +63,7 @@ func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateComm
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
@ -100,15 +100,15 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt
} }
commentDto := dto.CommentDto{ commentDto := dto.CommentDto{
ID: comment.ID, ID: comment.ID,
TargetID: comment.TargetID, TargetID: comment.TargetID,
TargetType: comment.TargetType, TargetType: comment.TargetType,
Content: comment.Content, Content: comment.Content,
ReplyID: comment.ReplyID, ReplyID: comment.ReplyID,
Depth: comment.Depth, Depth: comment.Depth,
CreatedAt: comment.CreatedAt.String(), CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(), UpdatedAt: comment.UpdatedAt.String(),
User: *comment.User.ToDto(), User: comment.User.ToDto(),
} }
return &commentDto, err return &commentDto, err
@ -132,8 +132,8 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
TargetType: comment.TargetType, TargetType: comment.TargetType,
CreatedAt: comment.CreatedAt.String(), CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(), UpdatedAt: comment.UpdatedAt.String(),
Depth: comment.Depth, Depth: comment.Depth,
User: *comment.User.ToDto(), User: comment.User.ToDto(),
} }
commentDtos = append(commentDtos, commentDto) commentDtos = append(commentDtos, commentDto)
} }

View File

@ -76,18 +76,7 @@ func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, err
if post.IsPrivate && (!ok || post.UserID != currentUser.ID) { if post.IsPrivate && (!ok || post.UserID != currentUser.ID) {
return nil, errs.ErrForbidden return nil, errs.ErrForbidden
} }
return &dto.PostDto{ return post.ToDto(), nil
UserID: post.UserID,
Title: post.Title,
Content: post.Content,
Labels: func() []dto.LabelDto {
labelDtos := make([]dto.LabelDto, 0)
for _, label := range post.Labels {
labelDtos = append(labelDtos, label.ToDto())
}
return labelDtos
}(),
}, nil
} }
func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) (uint, error) { func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) (uint, error) {
@ -124,8 +113,8 @@ func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.Create
return post.ID, nil return post.ID, nil
} }
func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]dto.PostDto, error) { func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, error) {
postDtos := make([]dto.PostDto, 0) postDtos := make([]*dto.PostDto, 0)
currentUserID, _ := ctxutils.GetCurrentUserID(ctx) currentUserID, _ := ctxutils.GetCurrentUserID(ctx)
posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderBy, req.Desc) posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderBy, req.Desc)
if err != nil { if err != nil {

View File

@ -1,9 +1,9 @@
import axios from 'axios' import axios from 'axios'
import { camelToSnakeObj, snakeToCamelObj } from 'field-conv' import { camelToSnakeObj, snakeToCamelObj } from 'field-conv'
export const BACKEND_URL = process.env.BACKEND_URL || 'http://neo-blog-backend:8888' export const BACKEND_URL = process.env.BACKEND_URL || (process.env.NODE_ENV == "production" ? 'http://neo-blog-backend:8888' : 'http://localhost:8888')
console.info(`Using backend URL: ${BACKEND_URL}`) console.info(`Using ${process.env.NODE_ENV} backend URL: ${BACKEND_URL}`)
const isServer = typeof window === 'undefined' const isServer = typeof window === 'undefined'

View File

@ -11,9 +11,8 @@ interface ListPostsParams {
} }
export async function getPostById(id: string): Promise<Post | null> { export async function getPostById(id: string): Promise<Post | null> {
console.log('Fetching post by ID:', id)
try { try {
const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/19`) const res = await axiosClient.get<BaseResponse<Post>>(`/post/p/${id}`)
return res.data.data return res.data.data
} }
catch (error) { catch (error) {

View File

@ -1,8 +1,9 @@
'use client' 'use client'
import { AnimatePresence, motion } from 'framer-motion' import { motion } from 'framer-motion'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { Navbar } from '@/components/navbar' import { Navbar } from '@/components/navbar'
import { BackgroundProvider } from '@/contexts/background-context'
export default function RootLayout({ export default function RootLayout({
children, children,
@ -15,22 +16,21 @@ export default function RootLayout({
<header className="fixed top-0 left-0 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800"> <header className="fixed top-0 left-0 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
<Navbar /> <Navbar />
</header> </header>
<AnimatePresence mode="wait"> <motion.main
<motion.main key={pathname}
key={pathname} initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }} transition={{
exit={{ opacity: 0, y: 16 }} type: 'tween',
transition={{ ease: 'easeOut',
type: 'tween', duration: 0.30,
ease: 'easeOut', }}
duration: 0.18, className="pt-16"
}} >
className="pt-16" <BackgroundProvider>
> <div className='container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
{children} </BackgroundProvider>
</motion.main> </motion.main>
</AnimatePresence>
</> </>
) )
} }

View File

@ -3,6 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { DeviceProvider } from "@/contexts/device-context"; import { DeviceProvider } from "@/contexts/device-context";
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import config from "@/config";
import { getUserLocales } from "@/i18n/request";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -15,17 +17,17 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: config.metadata.name,
description: "Generated by create next app", description: config.metadata.description,
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang={(await getUserLocales())[0] || "en"} className="h-full">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >

View File

@ -9,10 +9,11 @@ import type { Label } from "@/models/label";
import type { Post } from "@/models/post"; import type { Post } from "@/models/post";
import { listPosts } from "@/api/post"; import { listPosts } from "@/api/post";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useStoredState } from '@/hooks/use-storage-state'; import { useStoredState } from '@/hooks/use-storage-state';
import { listLabels } from "@/api/label"; import { listLabels } from "@/api/label";
import { POST_SORT_TYPE } from "@/localstore"; import { POST_SORT_TYPE } from "@/localstore";
import Image from "next/image";
// 定义排序类型 // 定义排序类型
type SortType = 'latest' | 'popular'; type SortType = 'latest' | 'popular';
@ -84,7 +85,7 @@ export default function BlogHome() {
{/* 主内容区域 */} {/* 主内容区域 */}
<section className="py-16"> <section className="py-16">
{/* 容器 - 关键布局 */} {/* 容器 - 关键布局 */}
<div className="container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl"> <div className="">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 主要内容区域 */} {/* 主要内容区域 */}
<div className="lg:col-span-3 self-start"> <div className="lg:col-span-3 self-start">

View File

@ -1,94 +1,99 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import Image from "next/image";
import type { Post } from "@/models/post"; import type { Post } from "@/models/post";
import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, SquarePen } from "lucide-react";
function WaveHeader({ title }: { title: string }) { function PostMeta({ post }: { post: Post }) {
// 假设 post 结构包含这些字段
// post.author, post.wordCount, post.readMinutes, post.createdAt, post.isOriginal, post.viewCount, post.commentCount
return ( return (
<div className="relative h-64 flex flex-col items-center justify-center"> console.log(post),
{/* 波浪SVG半透明悬浮 */} <div className="flex flex-wrap items-center gap-4 mt-6">
<div className="absolute inset-0 w-full h-full pointer-events-none opacity-70 z-0"> {/* 作者 */}
<svg className="w-full h-full" viewBox="0 0 1440 320" preserveAspectRatio="none"> <span className="flex items-center gap-1">
<defs> <PenLine className="w-4 h-4" />
<linearGradient id="wave-gradient" x1="0" y1="0" x2="1" y2="1"> {post.user.nickname || "未知作者"}
<stop offset="0%" stopColor="#4f8cff" /> </span>
<stop offset="100%" stopColor="#7b61ff" /> {/* 字数 */}
</linearGradient> <span className="flex items-center gap-1">
</defs> <FileText className="w-4 h-4" />
<path {post.content.length || 0}
fill="url(#wave-gradient)" </span>
fillOpacity="1" {/* 阅读时间 */}
d=" <span className="flex items-center gap-1">
M0,160 <Clock className="w-4 h-4" />
C360,240 1080,80 1440,160 {post.content.length / 100 || 1}
L1440,320 </span>
L0,320 {/* 发布时间 */}
Z <span className="flex items-center gap-1">
" <Calendar className="w-4 h-4" />
> {post.createdAt ? new Date(post.createdAt).toLocaleDateString("zh-CN") : ""}
<animate </span>
attributeName="d" {/* 最后编辑时间,如果和发布时间不一样 */}
dur="8s" {post.updatedAt && post.createdAt !== post.updatedAt && (
repeatCount="indefinite" <span className="flex items-center gap-1">
values=" <SquarePen className="w-4 h-4" />
M0,160 C360,240 1080,80 1440,160 L1440,320 L0,320 Z; {new Date(post.updatedAt).toLocaleDateString("zh-CN")}
M0,120 C400,200 1040,120 1440,200 L1440,320 L0,320 Z; </span>
M0,160 C360,240 1080,80 1440,160 L1440,320 L0,320 Z
"
/>
</path>
</svg>
</div>
{/* 标题 */}
<h1 className="relative z-10 text-white text-4xl md:text-5xl font-bold drop-shadow-lg mt-16 text-center">
{title}
</h1>
</div>
);
}
function BlogMeta({ post }: { post: Post }) {
return (
<div className="flex flex-col items-center mb-6">
<div className="flex gap-2 mb-2">
{post.labels?.map(label => (
<span
key={label.id}
className="bg-white/30 px-3 py-1 rounded-full text-sm font-medium backdrop-blur text-white"
>
{label.key}
</span>
))}
</div>
<div className="flex flex-wrap gap-4 text-base text-white/90">
<span> {new Date(post.createdAt).toLocaleDateString()}</span>
<span>👁 {post.viewCount}</span>
<span>💬 {post.commentCount}</span>
<span>🔥 {post.heat}</span>
</div>
</div>
);
}
function BlogContent({ post }: { post: Post }) {
return (
<main className="relative z-10 max-w-3xl mx-auto bg-white rounded-xl shadow-lg p-8 -mt-32">
{post.cover && (
<Image
src={post.cover}
alt="cover"
className="w-full h-64 object-cover rounded-lg mb-8"
/>
)} )}
<article {/* 浏览数 */}
className="prose prose-lg max-w-none" <span className="flex items-center gap-1">
dangerouslySetInnerHTML={{ __html: post.content }} <Flame className="w-4 h-4" />
/> {post.viewCount || 0}
</main> </span>
{/* 点赞数 */}
<span className="flex items-center gap-1">
<Heart className="w-4 h-4" />
{post.likeCount || 0}
</span>
{/* 评论数 */}
<span className="flex items-center gap-1">
<MessageCircle className="w-4 h-4" />
{post.commentCount || 0}
</span>
{/* 热度 */}
<span className="flex items-center gap-1">
<Flame className="w-4 h-4" />
{post.heat || 0}
</span>
</div>
); );
} }
function PostHeader({ post }: { post: Post }) {
// 三排 标签/标题/一些元数据
return (
<div className="py-32">
{
post.labels && post.labels.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.labels.map(label => (
<span key={label.id} className="bg-blue-100 text-blue-600 text-xs px-2 py-1 rounded">
{label.key}
</span>
))}
</div>
)
}
<h1 className="text-5xl font-bold mb-2">{post.title}</h1>
{/* 元数据区 */}
<div>
<PostMeta post={post} />
</div>
</div>
);
}
function PostContent({ post }: { post: Post }) {
return (
<div className="">
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
)
}
function BlogPost({ post }: { post: Post }) { function BlogPost({ post }: { post: Post }) {
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -96,11 +101,10 @@ function BlogPost({ post }: { post: Post }) {
} }
}, []); }, []);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 relative"> <div className="">
<WaveHeader title={post.title} /> <PostHeader post={post} />
<div className="relative z-10 -mt-40"> <div className="">
<BlogMeta post={post} /> <PostContent post={post} />
<BlogContent post={post} />
</div> </div>
</div> </div>
); );

View File

@ -44,8 +44,9 @@ const navbarMenuComponents = [
] ]
export function Navbar() { export function Navbar() {
const { navbarAdditionalClassName } = useDevice()
return ( return (
<nav className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full"> <nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full ${navbarAdditionalClassName}`}>
<div className="flex items-center justify-start"> <div className="flex items-center justify-start">
<span className="font-bold truncate">{config.metadata.name}</span> <span className="font-bold truncate">{config.metadata.name}</span>
</div> </div>
@ -62,7 +63,6 @@ export function Navbar() {
function NavMenuCenter() { function NavMenuCenter() {
const { isMobile } = useDevice() const { isMobile } = useDevice()
console.log("isMobile", isMobile)
if (isMobile) return null if (isMobile) return null
return ( return (
<NavigationMenu viewport={false}> <NavigationMenu viewport={false}>
@ -121,10 +121,10 @@ function ListItem({
} }
function SidebarMenuClientOnly() { function SidebarMenuClientOnly() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
if (!mounted) return null; if (!mounted) return null;
return <SidebarMenu />; return <SidebarMenu />;
} }
function SidebarMenu() { function SidebarMenu() {

View File

@ -11,7 +11,9 @@ const config = {
motto: "And now that story unfolds into a journey that, alone, I set out to", motto: "And now that story unfolds into a journey that, alone, I set out to",
avatar: "https://cdn.liteyuki.org/snowykami/avatar.jpg", avatar: "https://cdn.liteyuki.org/snowykami/avatar.jpg",
gravatarEmail: "snowykami@outlook.com" gravatarEmail: "snowykami@outlook.com"
} },
bodyWidth: "80vw",
bodyWidthMobile: "100vw",
} }
export default config export default config

View File

@ -0,0 +1,60 @@
import React, { createContext, useContext, useState, useCallback } from "react";
interface BackgroundContextType {
background: React.ReactNode;
setBackground: (bg: React.ReactNode) => void;
resetBackground: () => void;
}
const BackgroundContext = createContext<BackgroundContextType | undefined>(undefined);
export const BackgroundProvider: React.FC<{ children: React.ReactNode; defaultBackground?: React.ReactNode }> = ({
children,
defaultBackground = null,
}) => {
const [background, setBackgroundState] = useState<React.ReactNode>(defaultBackground);
const setBackground = useCallback((bg: React.ReactNode) => {
setBackgroundState(bg);
}, []);
const resetBackground = useCallback(() => {
setBackgroundState(defaultBackground);
}, [defaultBackground]);
return (
<BackgroundContext.Provider value={{ background, setBackground, resetBackground }}>
{/* 背景节点自动渲染在 children 之下 */}
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{background && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
aria-hidden="true"
>
{background}
</div>
)}
<div style={{ position: "relative", zIndex: 1 }}>{children}</div>
</div>
</BackgroundContext.Provider>
);
};
export function useBackground() {
const ctx = useContext(BackgroundContext);
if (!ctx) throw new Error("useBackground must be used within a BackgroundProvider");
return ctx;
}
// 便于直接解构使用
export const backgroundContext = {
useBackground,
BackgroundProvider,
};

View File

@ -13,6 +13,8 @@ interface DeviceContextProps {
width: number; width: number;
height: number; height: number;
}; };
navbarAdditionalClassName?: string; // 可选属性,允许传入额外的类名
setNavbarAdditionalClassName?: (className: string) => void; // 可选方法,允许设置额外的类名
} }
const DeviceContext = createContext<DeviceContextProps>({ const DeviceContext = createContext<DeviceContextProps>({
@ -24,6 +26,8 @@ const DeviceContext = createContext<DeviceContextProps>({
width: 0, width: 0,
height: 0, height: 0,
}, },
navbarAdditionalClassName: "",
setNavbarAdditionalClassName: () => {},
}); });
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@ -33,6 +37,7 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
width: typeof window !== "undefined" ? window.innerWidth : 0, width: typeof window !== "undefined" ? window.innerWidth : 0,
height: typeof window !== "undefined" ? window.innerHeight : 0, height: typeof window !== "undefined" ? window.innerHeight : 0,
}); });
const [navbarAdditionalClassName, setNavbarAdditionalClassName] = useState<string>("");
// 检查系统主题 // 检查系统主题
const getSystemTheme = () => const getSystemTheme = () =>
@ -109,7 +114,7 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
return ( return (
<DeviceContext.Provider <DeviceContext.Provider
value={{ isMobile, mode, setMode, toggleMode, viewport }} value={{ isMobile, mode, setMode, toggleMode, viewport, navbarAdditionalClassName, setNavbarAdditionalClassName }}
> >
{children} {children}
</DeviceContext.Provider> </DeviceContext.Provider>

View File

@ -10,7 +10,8 @@ export default getRequestConfig(async () => {
try { try {
return (await import(`@/locales/${locale}.json`)).default; return (await import(`@/locales/${locale}.json`)).default;
} catch (err) { } catch (err) {
console.error(err) console.warn();
(err)
return {}; return {};
} }
}) })

View File

@ -1,4 +1,5 @@
import type { Label } from "@/models/label"; import type { Label } from "@/models/label";
import type { User } from "./user";
export interface Post { export interface Post {
id: number; id: number;
@ -7,7 +8,9 @@ export interface Post {
cover: string | null; // 封面可以为空 cover: string | null; // 封面可以为空
type: "markdown" | "html" | "text"; type: "markdown" | "html" | "text";
labels: Label[] | null; // 标签可以为空 labels: Label[] | null; // 标签可以为空
user: User
isPrivate: boolean; isPrivate: boolean;
isOriginal: boolean;
likeCount: number; likeCount: number;
commentCount: number; commentCount: number;
viewCount: number; viewCount: number;