From 7c8fd2ca3e5efcbb4fe7bf0317717eda88fd8a4d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 30 Sep 2026 21:50:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(about):=20=E6=B7=BB=E5=8A=A0=E5=85=B3?= =?UTF-8?q?=E4=BA=8E=E6=88=91=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E5=90=AB?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E4=BF=A1=E6=81=AF=E7=AE=A1=E7=90=86=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=8F=8A=E5=89=8D=E7=AB=AF=E5=B1=95=E7=A4=BA=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 3 + backend/internal/handler/about.go | 55 ++ backend/internal/handler/router.go | 7 +- backend/internal/model/system.go | 42 +- backend/internal/service/about.go | 128 ++++ .../src/app/(admin)/admin/settings/page.tsx | 582 +++++++++++++++++- frontend/src/app/(blog)/about/page.tsx | 112 +++- frontend/src/components/about/AboutMain.tsx | 141 +++++ .../src/components/about/AboutSidebar.tsx | 142 +++++ .../src/components/about/AvatarUploader.tsx | 108 ++++ .../src/components/about/BannerUploader.tsx | 100 +++ frontend/src/hooks/useIsAdmin.ts | 25 + frontend/src/lib/about.ts | 102 +++ 13 files changed, 1486 insertions(+), 61 deletions(-) create mode 100644 backend/internal/handler/about.go create mode 100644 backend/internal/service/about.go create mode 100644 frontend/src/components/about/AboutMain.tsx create mode 100644 frontend/src/components/about/AboutSidebar.tsx create mode 100644 frontend/src/components/about/AvatarUploader.tsx create mode 100644 frontend/src/components/about/BannerUploader.tsx create mode 100644 frontend/src/hooks/useIsAdmin.ts create mode 100644 frontend/src/lib/about.ts diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b9c7325..a6d92c8 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -60,6 +60,7 @@ func main() { albumService := service.NewAlbumService(config.DB) linkService := service.NewLinkService(config.DB) blogInfoService := service.NewBlogInfoService(config.DB) + aboutService := service.NewAboutService(config.DB) logService := service.NewLogService(config.DB) uploadService := service.NewUploadService(cfg.Upload.Mode, cfg.Upload.LocalPath, cfg.Upload.MaxSize) @@ -76,6 +77,7 @@ func main() { albumHandler := handler.NewAlbumHandler(albumService) linkHandler := handler.NewLinkHandler(linkService) blogInfoHandler := handler.NewBlogInfoHandler(blogInfoService, logService) + aboutHandler := handler.NewAboutHandler(aboutService) uploadHandler := handler.NewUploadHandler(uploadService) deps := &handler.Deps{ @@ -90,6 +92,7 @@ func main() { AlbumHandler: albumHandler, LinkHandler: linkHandler, BlogInfoHandler: blogInfoHandler, + AboutHandler: aboutHandler, UploadHandler: uploadHandler, UploadPath: cfg.Upload.LocalPath, DB: config.DB, diff --git a/backend/internal/handler/about.go b/backend/internal/handler/about.go new file mode 100644 index 0000000..92bed3d --- /dev/null +++ b/backend/internal/handler/about.go @@ -0,0 +1,55 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "github.com/tajiaoyezi/blog-go-next/backend/internal/model" + "github.com/tajiaoyezi/blog-go-next/backend/internal/service" +) + +// AboutHandler 「关于我」接口处理器。 +// +// 拆分自 BlogInfoHandler 是因为「关于我」是结构化数据(多字段), +// 与旧版 BlogInfoHandler.UpdateAbout 把整段 markdown 当字符串塞进 +// WebsiteConfig.config JSON 的设计不同——新设计把整张表独立出来, +// 用专门的字段表达每个语义单元。 +type AboutHandler struct { + svc *service.AboutService +} + +func NewAboutHandler(svc *service.AboutService) *AboutHandler { + return &AboutHandler{svc: svc} +} + +// GetAbout 公开接口,返回当前「关于我」配置(不存在时返回默认值) +func (h *AboutHandler) GetAbout(c *gin.Context) { + about, err := h.svc.GetAbout(c.Request.Context()) + if err != nil { + FailServer(c, err.Error()) + return + } + OK(c, about) +} + +// UpdateAbout 管理员更新「关于我」配置。 +// +// 注意:直接绑定 model.About 结构体——gin 会忽略空字段的非零校验, +// 允许部分字段更新。但 ID/CreateTime/UpdateTime 这类字段会暴露在请求里, +// 因此 handler 在写库前由 service 重置 ID/CreateTime,避免越权覆盖。 +func (h *AboutHandler) UpdateAbout(c *gin.Context) { + var req model.About + if err := c.ShouldBindJSON(&req); err != nil { + FailValidation(c, "请求格式错误") + return + } + // 防止前端意外把空字符串当成"清空昵称" + if req.Nickname == "" { + FailValidation(c, "昵称不能为空") + return + } + if err := h.svc.UpdateAbout(c.Request.Context(), &req); err != nil { + FailServer(c, err.Error()) + return + } + OK(c, nil) +} \ No newline at end of file diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go index 865033a..d55f83d 100644 --- a/backend/internal/handler/router.go +++ b/backend/internal/handler/router.go @@ -24,6 +24,7 @@ type Deps struct { AlbumHandler *AlbumHandler LinkHandler *LinkHandler BlogInfoHandler *BlogInfoHandler + AboutHandler *AboutHandler UploadHandler *UploadHandler UploadPath string // 本地上传文件根目录,用于静态文件服务 @@ -123,6 +124,9 @@ func setupPublicRoutes(rg *gin.RouterGroup, deps *Deps, rdb *redis.Client) { // 站点信息 rg.GET("/website/config", deps.BlogInfoHandler.GetWebsiteConfig) + // 关于我(公开) + rg.GET("/about", deps.AboutHandler.GetAbout) + // 认证(真实 handler,带限流防爆破/发信滥用) rg.POST("/register", middleware.RateLimit(rdb, 3, time.Minute), deps.AuthHandler.Register) rg.POST("/login", middleware.RateLimit(rdb, 5, time.Minute), deps.AuthHandler.Login) @@ -230,7 +234,8 @@ func setupAdminRoutes(rg *gin.RouterGroup, deps *Deps) { // 站点配置 rg.PUT("/website/config", deps.BlogInfoHandler.UpdateWebsiteConfig) - rg.PUT("/about", deps.BlogInfoHandler.UpdateAbout) + rg.PUT("/about", deps.AboutHandler.UpdateAbout) + rg.GET("/about", deps.AboutHandler.GetAbout) // 操作日志 rg.GET("/operation/logs", deps.BlogInfoHandler.ListLogs) diff --git a/backend/internal/model/system.go b/backend/internal/model/system.go index be2ba5a..726960b 100644 --- a/backend/internal/model/system.go +++ b/backend/internal/model/system.go @@ -95,17 +95,39 @@ func (UniqueView) TableName() string { return "tb_unique_view" } // 这样无需在前端处理「配置尚未初始化」的分支,简化读写语义。 type About struct { BaseModel - Avatar string `gorm:"type:varchar(1024);not null;default:''" json:"avatar"` - Nickname string `gorm:"type:varchar(50);not null" json:"nickname"` - Intro string `gorm:"type:varchar(255)" json:"intro"` - Bio string `gorm:"type:text" json:"bio"` - Email string `gorm:"type:varchar(100)" json:"email"` - Website string `gorm:"type:varchar(255)" json:"website"` - Github string `gorm:"type:varchar(255)" json:"github"` - TechStack string `gorm:"type:text" json:"techStack"` - BackendSkills string `gorm:"type:text" json:"backendSkills"` + // 顶部 banner 大图(关于页右上方的展示图) + Banner string `gorm:"type:varchar(1024);not null;default:''" json:"banner"` + // 头像 + Avatar string `gorm:"type:varchar(1024);not null;default:''" json:"avatar"` + // 昵称(侧边栏和 "Hi, I'm X" 主标题共用) + Nickname string `gorm:"type:varchar(50);not null" json:"nickname"` + // 代词 / pronouns(昵称下方一行,如 he/him、she/her) + Pronouns string `gorm:"type:varchar(50);not null;default:''" json:"pronouns"` + // 简短一句话介绍(昵称下方 + 邮箱前的 bio) + Intro string `gorm:"type:varchar(255);not null;default:''" json:"intro"` + // 主标题下方的副标题(红字显示,如 "AI Agent") + Subtitle string `gorm:"type:varchar(100);not null;default:''" json:"subtitle"` + // 详细 bio / markdown 文本(用于 About Me 区域) + Bio string `gorm:"type:text" json:"bio"` + // About Me 区域的项目列表(JSON:[{emoji, text}]) + AboutPoints string `gorm:"type:text;not null;default:'[]'" json:"aboutPoints"` + // 邮箱 + Email string `gorm:"type:varchar(100)" json:"email"` + // 个人网站链接 + Website string `gorm:"type:varchar(255)" json:"website"` + // Github 链接 + Github string `gorm:"type:varchar(255)" json:"github"` + // 关注的粉丝数 / 关注数(仅展示用,由管理员手填) + Followers int `gorm:"not null;default:0" json:"followers"` + Following int `gorm:"not null;default:0" json:"following"` + // 技术栈展示(icon + 名称的 JSON 字符串:[{"name","icon","color"}]) + TechStack string `gorm:"type:text;not null;default:'[]'" json:"techStack"` + // 后端 / 前端 / 其他技能列表(每项一个字符串,简单换行展示) + BackendSkills string `gorm:"type:text" json:"backendSkills"` FrontendSkills string `gorm:"type:text" json:"frontendSkills"` - OtherSkills string `gorm:"type:text" json:"otherSkills"` + OtherSkills string `gorm:"type:text" json:"otherSkills"` + // 成就 / 徽章(JSON:[{"name","icon","color"}]) + Achievements string `gorm:"type:text;not null;default:'[]'" json:"achievements"` } func (About) TableName() string { return "tb_about" } diff --git a/backend/internal/service/about.go b/backend/internal/service/about.go new file mode 100644 index 0000000..8f8fd17 --- /dev/null +++ b/backend/internal/service/about.go @@ -0,0 +1,128 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + + "gorm.io/gorm" + + "github.com/tajiaoyezi/blog-go-next/backend/internal/model" +) + +// AboutService 个人信息(关于页内容)的读写服务。 +// +// 单行表设计:整站只维护一份「关于我」。读路径在表为空时返回一份默认值, +// 写路径使用 upsert,保证无论管理员是首次保存还是后续编辑都写入第一行。 +type AboutService struct { + db *gorm.DB +} + +func NewAboutService(db *gorm.DB) *AboutService { + return &AboutService{db: db} +} + +// defaultAbout 用于表为空时的占位默认值,避免前端在初次部署时拿到零值。 +// +// 字段保持与 model.About 对齐;JSON 数组型字段返回合法空数组, +// 让前端无需做 null 兜底,直接遍历即可。 +func defaultAbout() model.About { + return model.About{ + Nickname: "提子", + Pronouns: "he/him", + Intro: "🍊 Hi, I'm 提子. Learning full-stack development.", + Subtitle: "Hello World", + AboutPoints: "[{\"emoji\":\"🌱\",\"text\":\"热爱技术与开源,持续学习与实践\"}]", + TechStack: "[]", + Achievements: "[]", + Avatar: "", + Banner: "", + Bio: "", + Email: "", + Website: "", + Github: "", + Followers: 0, + Following: 0, + BackendSkills: "", + FrontendSkills: "", + OtherSkills: "", + } +} + +// GetAbout 读取「关于我」配置;不存在时返回默认值而不报错, +// 让前端无需处理「首次部署无配置」的特殊分支。 +func (s *AboutService) GetAbout(ctx context.Context) (*model.About, error) { + var about model.About + if err := s.db.WithContext(ctx).First(&about).Error; err != nil { + if err == gorm.ErrRecordNotFound { + def := defaultAbout() + return &def, nil + } + return nil, fmt.Errorf("读取关于我配置失败: %w", err) + } + return &about, nil +} + +// UpdateAbout 写入「关于我」配置(upsert 到首行)。 +// +// 入参来自 handler 的请求体,前端在用户未填的 JSON 数组字段上传的是 "[]", +// 这里做一次兜底校验:JSON 数组字段如果反序列化失败则强制为空数组, +// 避免数据库里残留非法 JSON 导致后续解析异常。 +func (s *AboutService) UpdateAbout(ctx context.Context, in *model.About) error { + if in == nil { + return fmt.Errorf("请求体为空") + } + in.AboutPoints = normalizeJSONArray(in.AboutPoints) + in.TechStack = normalizeJSONArray(in.TechStack) + in.Achievements = normalizeJSONArray(in.Achievements) + + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing model.About + err := tx.First(&existing).Error + if err == gorm.ErrRecordNotFound { + return tx.Create(in).Error + } + if err != nil { + return fmt.Errorf("读取关于我配置失败: %w", err) + } + in.ID = existing.ID + in.CreateTime = existing.CreateTime + return tx.Save(in).Error + }) +} + +// normalizeJSONArray 把 JSON 数组字符串校验/归一化为合法数组。 +// 不是合法 JSON(或为空)时返回 "[]",避免存储污染。 +func normalizeJSONArray(raw string) string { + raw = trimSpace(raw) + if raw == "" { + return "[]" + } + var probe interface{} + if err := json.Unmarshal([]byte(raw), &probe); err != nil { + return "[]" + } + if _, ok := probe.([]interface{}); !ok { + return "[]" + } + return raw +} + +func trimSpace(s string) string { + start, end := 0, len(s) + for start < end { + c := s[start] + if c != ' ' && c != '\n' && c != '\r' && c != '\t' { + break + } + start++ + } + for end > start { + c := s[end-1] + if c != ' ' && c != '\n' && c != '\r' && c != '\t' { + break + } + end-- + } + return s[start:end] +} \ No newline at end of file diff --git a/frontend/src/app/(admin)/admin/settings/page.tsx b/frontend/src/app/(admin)/admin/settings/page.tsx index 8f7e890..90d2b19 100644 --- a/frontend/src/app/(admin)/admin/settings/page.tsx +++ b/frontend/src/app/(admin)/admin/settings/page.tsx @@ -5,14 +5,53 @@ import { api } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { toast } from "sonner"; +import { Plus, Trash2 } from "lucide-react"; +import { + getAbout, + updateAbout, + uploadImage, + type AboutInfo, + type AboutBadge, +} from "@/lib/about"; export default function SettingsPage() { + return ( +
+

站点设置

+ + + 关于我 + JSON 配置 + + + + + + + + +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* JSON 配置(保留原功能) */ +/* -------------------------------------------------------------------------- */ + +function JsonConfigSettings() { const [config, setConfig] = useState(""); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [error, setError] = useState(""); useEffect(() => { @@ -36,7 +75,6 @@ export default function SettingsPage() { }, []); const handleSave = async () => { - // 验证 JSON 格式 try { JSON.parse(config); } catch { @@ -47,7 +85,6 @@ export default function SettingsPage() { setSaving(true); try { const parsed = JSON.parse(config); - // 校验顶层结构:必须是普通对象,拒绝危险键 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { toast.error("配置必须是 JSON 对象"); setSaving(false); @@ -102,33 +139,540 @@ export default function SettingsPage() { } return ( -
-

站点配置

+ + + JSON 配置 + + +
+ +