️ feat: add main page layout with navigation and footer

feat: create random labels page

feat: implement login page with OpenID Connect support

feat: add Gravatar component for user avatars

feat: create Navbar component with navigation menu

chore: create Sidebar component placeholder

feat: implement login form with OIDC and email/password options

feat: add reusable button component

feat: create card component for structured content display

feat: implement input component for forms

feat: create label component for form labels

feat: add navigation menu component for site navigation

chore: add configuration file for site metadata

feat: implement device context for responsive design

feat: add utility functions for class name management

feat: define OIDC configuration model

feat: define base response model for API responses

feat: define user model for user data

feat: implement i18n for internationalization support

feat: add English and Chinese translations for login

chore: create index for locale resources

chore: add blog home view placeholder
This commit is contained in:
2025-07-24 09:22:50 +08:00
parent 19c8a9eac5
commit 9ca307f4d9
57 changed files with 2453 additions and 108 deletions

View File

@ -1 +1,9 @@
package model
import "gorm.io/gorm"
type Category struct {
gorm.Model
Name string `gorm:"type:text;not null"`
Description string `gorm:"type:text;not null"` // 分类描述
}

View File

@ -1,11 +1,24 @@
package model
import "gorm.io/gorm"
import (
"github.com/snowykami/neo-blog/internal/dto"
"gorm.io/gorm"
)
type Label struct {
gorm.Model
Key string `gorm:"uniqueIndex"` // 标签键,唯一标识
Value string `gorm:"type:text"` // 标签值,描述标签的内容
Color string `gorm:"type:text"` // 前端可用颜色代码
TailwindClassName string `gorm:"type:text"` // Tailwind CSS 的类名,用于前端样式
Key string `gorm:"uniqueIndex"` // 标签键,唯一标识
Value string `gorm:"type:text"` // 标签值,描述标签的内容
Color string `gorm:"type:text"` // 前端可用颜色代码
TailwindClassName string `gorm:"type:text"` // Tailwind CSS 的类名,用于前端样式
Posts []Post `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的帖子
}
func (l *Label) ToDto() dto.LabelDto {
return dto.LabelDto{
Key: l.Key,
Value: l.Value,
Color: l.Color,
TailwindClassName: l.TailwindClassName,
}
}

View File

@ -2,6 +2,7 @@ package model
import (
"fmt"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/dto"
"gorm.io/gorm"
"resty.dev/v3"
@ -17,6 +18,7 @@ type OidcConfig struct {
Icon string // 图标url为空则使用内置默认图标
OidcDiscoveryUrl string // OpenID自动发现URL例如 https://pass.liteyuki.icu/.well-known/openid-configuration
Enabled bool `gorm:"default:true"` // 是否启用
Type string `gorm:"oauth2"` // OIDC类型默认为oauth2,也可以为misskey
// 以下字段为自动获取字段,每次更新配置时自动填充
Issuer string
AuthorizationEndpoint string
@ -42,7 +44,7 @@ type oidcDiscoveryResp struct {
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
}
func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) {
func updateOidcConfigFromUrl(url string, typ string) (*oidcDiscoveryResp, error) {
client := resty.New()
client.SetTimeout(10 * time.Second) // 设置超时时间
var discovery oidcDiscoveryResp
@ -57,6 +59,11 @@ func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) {
return nil, fmt.Errorf("请求OIDC发现端点失败状态码: %d", resp.StatusCode())
}
// 验证必要字段
if typ == "misskey" {
discovery.UserInfoEndpoint = discovery.Issuer + "/api/users/me" // Misskey的用户信息端点
discovery.JwksUri = discovery.Issuer + "/api/jwks"
}
fmt.Println(discovery)
if discovery.Issuer == "" ||
discovery.AuthorizationEndpoint == "" ||
discovery.TokenEndpoint == "" ||
@ -69,10 +76,12 @@ func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) {
func (o *OidcConfig) BeforeSave(tx *gorm.DB) (err error) {
// 只有在创建新记录或更新 OidcDiscoveryUrl 字段时才更新端点信息
if tx.Statement.Changed("OidcDiscoveryUrl") {
discoveryResp, err := updateOidcConfigFromUrl(o.OidcDiscoveryUrl)
if tx.Statement.Changed("OidcDiscoveryUrl") || o.ID == 0 {
logrus.Infof("Updating OIDC config for %s, OidcDiscoveryUrl: %s", o.Name, o.OidcDiscoveryUrl)
discoveryResp, err := updateOidcConfigFromUrl(o.OidcDiscoveryUrl, o.Type)
if err != nil {
return fmt.Errorf("更新OIDC配置失败: %w", err)
logrus.Error("Updating OIDC config failed: ", err)
return fmt.Errorf("updating OIDC config failed: %w", err)
}
o.Issuer = discoveryResp.Issuer
o.AuthorizationEndpoint = discoveryResp.AuthorizationEndpoint

View File

@ -1,16 +1,57 @@
package model
import "gorm.io/gorm"
import (
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/pkg/constant"
"gorm.io/gorm"
)
type Post struct {
gorm.Model
UserID uint `gorm:"index"` // 发布者的用户ID
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
Title string `gorm:"type:text;not null"` // 帖子标题
Content string `gorm:"type:text;not null"` // 帖子内容
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
IsPrivate bool `gorm:"default:false"` // 是否为私密帖子
UserID uint `gorm:"index"` // 发布者的用户ID
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
Title string `gorm:"type:text;not null"` // 帖子标题
Content string `gorm:"type:text;not null"` // 帖子内容
CategoryID uint `gorm:"index"` // 帖子分类ID
Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
IsPrivate bool `gorm:"default:false"` // 是否为私密帖子
LikeCount uint64
CommentCount uint64
VisitorCount uint64
ViewCount uint64
Heat uint64 `gorm:"default:0"`
}
// CalculateHeat 热度计算
func (p *Post) CalculateHeat() float64 {
return float64(
p.LikeCount*constant.HeatFactorLikeWeight +
p.CommentCount*constant.HeatFactorCommentWeight +
p.ViewCount*constant.HeatFactorViewWeight,
)
}
// AfterUpdate 热度指标更新后更新热度
func (p *Post) AfterUpdate(tx *gorm.DB) (err error) {
if tx.Statement.Changed("LikeCount") || tx.Statement.Changed("CommentCount") || tx.Statement.Changed("ViewCount") {
p.Heat = uint64(p.CalculateHeat())
if err := tx.Model(p).Update("heat", p.Heat).Error; err != nil {
return err
}
}
return nil
}
func (p *Post) ToDto() dto.PostDto {
return dto.PostDto{
ID: p.ID,
UserID: p.UserID,
Title: p.Title,
Content: p.Content,
IsPrivate: p.IsPrivate,
LikeCount: p.LikeCount,
CommentCount: p.CommentCount,
ViewCount: p.ViewCount,
Heat: p.Heat,
}
}