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 配置
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* 关于我 设置 */
+/* -------------------------------------------------------------------------- */
+
+const EMPTY_INFO: AboutInfo = {
+ banner: "",
+ avatar: "",
+ nickname: "",
+ pronouns: "",
+ intro: "",
+ subtitle: "",
+ bio: "",
+ aboutPoints: [],
+ email: "",
+ website: "",
+ github: "",
+ followers: 0,
+ following: 0,
+ techStack: [],
+ backendSkills: "",
+ frontendSkills: "",
+ otherSkills: "",
+ achievements: [],
+};
+
+function AboutSettings() {
+ const [info, setInfo] = useState
(EMPTY_INFO);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [uploadingField, setUploadingField] = useState<"" | "avatar" | "banner">("");
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ getAbout()
+ .then((data) => {
+ if (!cancelled) setInfo({ ...EMPTY_INFO, ...data });
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) {
+ toast.error(err instanceof Error ? err.message : "加载失败");
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const update = (key: K, value: AboutInfo[K]) => {
+ setInfo((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const handleSave = async () => {
+ if (!info.nickname.trim()) {
+ toast.error("昵称不能为空");
+ return;
+ }
+ setSaving(true);
+ try {
+ await updateAbout(info);
+ toast.success("关于我已保存");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "保存失败");
+ } finally {
+ setSaving(false);
+ }
+ };
+ const handleUploadImage = async (
+ field: "avatar" | "banner",
+ file: File,
+ ): Promise => {
+ setUploadingField(field);
+ try {
+ const url = await uploadImage(file);
+ update(field, url);
+ toast.success(`${field === "avatar" ? "头像" : "Banner"} 已上传`);
+ return url;
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "上传失败");
+ throw err;
+ } finally {
+ setUploadingField("");
+ }
+ };
+
+ if (loading) {
+ return (
+
+ 加载中...
+
+ );
+ }
+
+ return (
+
- JSON 配置
+ 基础信息
+
+ 关于页顶部展示的头像、Banner、昵称、简介等信息
+
+ {/* 头像 + Banner 上传 */}
+
+
+
+
+
+ {info.avatar ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ 无
+
+ )}
+
+
+ update("avatar", e.target.value)}
+ placeholder="可粘贴图片 URL,或点击右侧上传"
+ />
+ handleUploadImage("avatar", file)}
+ >
+ {uploadingField === "avatar" ? "上传中..." : "上传新头像"}
+
+
+
+
+
+
+
+
+
+ {info.banner ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ 无
+
+ )}
+
+
+ update("banner", e.target.value)}
+ placeholder="可粘贴图片 URL,或点击右侧上传"
+ />
+ handleUploadImage("banner", file)}
+ >
+ {uploadingField === "banner" ? "上传中..." : "上传新 Banner"}
+
+
+
+
+
+
+
+
-
+
-
-
-
+
+
+
+
+ update("subtitle", e.target.value)}
+ placeholder="如:AI Agent / Hello World"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* About Me 项目 */}
+
+
+ About Me 项目
+ 左侧 About Me 区域内的要点(带 emoji)
+
+
+ {info.aboutPoints.map((p, idx) => (
+
+ {
+ const next = [...info.aboutPoints];
+ next[idx] = { ...p, emoji: e.target.value };
+ update("aboutPoints", next);
+ }}
+ placeholder="🌱"
+ />
+
+ ))}
+
+
+
+
+ {/* 技术栈 + 成就 */}
+
+ update("techStack", next)}
+ />
+ update("achievements", next)}
+ />
+
+
+ {/* 技能列表 */}
+
+
+ 技能分类
+ 每行一项,用于 Skills 区域
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
+
+interface BadgeListCardProps {
+ title: string;
+ description: string;
+ items: AboutBadge[];
+ onChange: (next: AboutBadge[]) => void;
+}
+
+/**
+ * 通用徽章编辑卡片:名称 / icon(emoji 或文本)/ 背景色。
+ * 用于 Tech Stack 与 Achievements 两个区。
+ */
+function BadgeListCard({ title, description, items, onChange }: BadgeListCardProps) {
+ return (
+
+
+ {title}
+ {description}
+
+
+ {items.map((b, idx) => (
+
+ {
+ const next = [...items];
+ next[idx] = { ...b, icon: e.target.value };
+ onChange(next);
+ }}
+ placeholder="🚀"
+ maxLength={2}
+ />
+ {
+ const next = [...items];
+ next[idx] = { ...b, name: e.target.value };
+ onChange(next);
+ }}
+ placeholder="名称(如 Go)"
+ />
+ {
+ const next = [...items];
+ next[idx] = { ...b, color: e.target.value };
+ onChange(next);
+ }}
+ aria-label="背景色"
+ />
+
+
+ ))}
+
+
+
+ );
+}
+
+interface ImageUploadButtonProps {
+ onPick: (file: File) => Promise | void;
+ disabled?: boolean;
+ children: React.ReactNode;
+}
+
+/** 文件选择按钮;用户选完文件后调用 onPick,由父组件负责上传与回填 URL。 */
+function ImageUploadButton({ onPick, disabled, children }: ImageUploadButtonProps) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/(blog)/about/page.tsx b/frontend/src/app/(blog)/about/page.tsx
index c58af43..fdfaf85 100644
--- a/frontend/src/app/(blog)/about/page.tsx
+++ b/frontend/src/app/(blog)/about/page.tsx
@@ -1,39 +1,89 @@
"use client";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { useEffect, useState } from "react";
+import { getAbout, type AboutInfo } from "@/lib/about";
+import { useIsAdmin } from "@/hooks/useIsAdmin";
+import { AboutSidebar } from "@/components/about/AboutSidebar";
+import { AboutMain } from "@/components/about/AboutMain";
+import { Skeleton } from "@/components/ui/skeleton";
+/**
+ * 关于页:左侧个人卡片 + 右侧主内容。
+ *
+ * - 公共部分(头像、昵称、bio、tech stack 等)所有人都能看到;
+ * - 「编辑资料」按钮和头像/Banner 的 hover 上传交互仅对管理员暴露,
+ * 借 useIsAdmin hook 判断(token 存在即可编辑)。
+ *
+ * 注意:头像/Banner 上传后只更新本地预览,不会自动落库——落库需要
+ * 管理员在「站点设置」页点击「保存」按钮(avatar/banner 已合并进表单)。
+ */
export default function AboutPage() {
- return (
-
-
关于
+ const isAdmin = useIsAdmin();
+ const [data, setData] = useState
(null);
+ const [loading, setLoading] = useState(true);
+
+ // 头像 / Banner 走受控本地状态,保存时才一起写入接口
+ const [avatarDraft, setAvatarDraft] = useState("");
+ const [bannerDraft, setBannerDraft] = useState("");
+
+ useEffect(() => {
+ let cancelled = false;
+ getAbout()
+ .then((info) => {
+ if (cancelled) return;
+ setData(info);
+ setAvatarDraft(info.avatar);
+ setBannerDraft(info.banner);
+ })
+ .catch(() => {
+ if (!cancelled) setData(null);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
-
-
- 关于本站
-
-
-
- 欢迎来到我的个人博客!这里记录技术学习、项目经验和生活点滴。
-
-
- 本站使用 Go + Next.js 构建,前端基于 TailwindCSS 和 shadcn/ui。
-
-
-
+ if (loading) {
+ return (
+
+ );
+ }
-
-
- 关于我
-
-
- 一个热爱技术的全栈开发者。
-
- - 后端: Go / Java / Node.js
- - 前端: React / Next.js / Vue
- - 其他: Docker / Kubernetes / CI/CD
-
-
-
+ if (!data) {
+ return (
+ 暂无关于信息
+ );
+ }
+
+ return (
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/about/AboutMain.tsx b/frontend/src/components/about/AboutMain.tsx
new file mode 100644
index 0000000..fcf9405
--- /dev/null
+++ b/frontend/src/components/about/AboutMain.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import type { AboutInfo } from "@/lib/about";
+import { BannerUploader } from "./BannerUploader";
+
+interface AboutMainProps {
+ data: AboutInfo;
+ editable: boolean;
+ bannerValue: string;
+ onBannerChange?: (url: string) => void;
+}
+
+/**
+ * 关于页右侧主体:
+ * - 顶部 banner(可由管理员更换)
+ * - 主标题 "Hi, I'm [昵称]" + 副标题
+ * - "About Me" 项目列表(emoji + 文本)
+ * - "Tech Stack" 技术栈图标网格
+ * - 后端 / 前端 / 其他技能(若有)
+ */
+export function AboutMain({
+ data,
+ editable,
+ bannerValue,
+ onBannerChange,
+}: AboutMainProps) {
+ const hasBanner = Boolean(bannerValue);
+
+ return (
+
+ {hasBanner && (
+
onBannerChange?.(url)}
+ editable={editable}
+ />
+ )}
+
+ {/* Greeting 区块 */}
+
+
+ 🎉 Hi, I'm {data.nickname || "未命名"}
+
+ {data.subtitle && (
+
+ {data.subtitle}
+
+ )}
+
+
+ {data.aboutPoints.length > 0 && (
+
+
+ 🌱
+ About Me
+
+
+ {data.aboutPoints.map((p, idx) => (
+ -
+ {p.emoji || "•"}
+ {p.text}
+
+ ))}
+
+
+ )}
+
+ {data.techStack.length > 0 && (
+
+
+ 🛠
+ Tech Stack
+
+
+ {data.techStack.map((t, idx) => (
+
+ {t.icon || t.name?.[0]?.toUpperCase() || "?"}
+
+ ))}
+
+
+ )}
+
+ {(data.backendSkills || data.frontendSkills || data.otherSkills) && (
+
+
+ 💻
+ Skills
+
+
+ {data.backendSkills && (
+
+
+ Backend
+
+
+ {data.backendSkills}
+
+
+ )}
+ {data.frontendSkills && (
+
+
+ Frontend
+
+
+ {data.frontendSkills}
+
+
+ )}
+ {data.otherSkills && (
+
+
+ Other
+
+
{data.otherSkills}
+
+ )}
+
+
+ )}
+
+ {data.bio && (
+
+
+ 📝
+ Bio
+
+
+ {data.bio}
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/about/AboutSidebar.tsx b/frontend/src/components/about/AboutSidebar.tsx
new file mode 100644
index 0000000..03e67f5
--- /dev/null
+++ b/frontend/src/components/about/AboutSidebar.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import Link from "next/link";
+import { Pencil, Link2, Mail } from "lucide-react";
+import type { AboutInfo } from "@/lib/about";
+import { AvatarUploader } from "./AvatarUploader";
+import { cn } from "@/lib/utils";
+
+interface AboutSidebarProps {
+ data: AboutInfo;
+ editable: boolean;
+ /** 头像上传后回调(仅在 editable 时有效) */
+ onAvatarChange?: (url: string) => void;
+ /** 头像 URL 受控值(编辑场景下使用,传入 AvatarUploader) */
+ avatarValue: string;
+ className?: string;
+}
+
+/**
+ * 关于页左侧栏:
+ * [大头像] 昵称 · pronouns
+ * 一句话 intro
+ * [编辑资料] 按钮(管理员)
+ * followers · following
+ * 邮箱 + 网站链接
+ * Achievements 徽章
+ */
+export function AboutSidebar({
+ data,
+ editable,
+ onAvatarChange,
+ avatarValue,
+ className,
+}: AboutSidebarProps) {
+ return (
+
+ );
+}
+
+/** 显示用 URL:去掉协议头与末尾的 /,避免邮箱/链接占两行 */
+function prettyUrl(raw: string): string {
+ if (!raw) return "";
+ return raw.replace(/^https?:\/\//, "").replace(/\/$/, "");
+}
\ No newline at end of file
diff --git a/frontend/src/components/about/AvatarUploader.tsx b/frontend/src/components/about/AvatarUploader.tsx
new file mode 100644
index 0000000..63291da
--- /dev/null
+++ b/frontend/src/components/about/AvatarUploader.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { Camera } from "lucide-react";
+import { uploadImage } from "@/lib/about";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+
+interface AvatarUploaderProps {
+ /** 当前头像 URL */
+ value: string;
+ /** 上传成功后的回调(父组件拿到新 URL 自行保存到表单/接口) */
+ onChange: (url: string) => void;
+ /** 仅在管理员视角下启用,访客时隐藏换头像按钮 */
+ editable?: boolean;
+ /** 头像尺寸,默认 9rem(144px),对应侧边栏中的圆形大头像 */
+ size?: number;
+ className?: string;
+}
+
+/**
+ * 圆形头像组件 + hover 时显示「换头像」按钮。
+ *
+ * 设计要点:
+ * - 默认状态显示头像本身;hover 时浮一层遮罩 + 相机图标 + 文案。
+ * - 仅在 editable=true 时启用文件选择器,避免给非管理员暴露隐藏交互。
+ * - 上传走前端 `uploadImage()`(复用 admin 图片上传接口,含扩展名/MIME 校验)。
+ */
+export function AvatarUploader({
+ value,
+ onChange,
+ editable = false,
+ size = 144,
+ className,
+}: AvatarUploaderProps) {
+ const fileRef = useRef(null);
+ const [uploading, setUploading] = useState(false);
+
+ const handlePick = () => {
+ if (!editable || uploading) return;
+ fileRef.current?.click();
+ };
+
+ const handleFile = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ e.target.value = ""; // 允许重复选同一文件
+ if (!file) return;
+ setUploading(true);
+ try {
+ const url = await uploadImage(file);
+ onChange(url);
+ toast.success("头像已更新,记得保存设置哦~");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "上传失败");
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ return (
+
+ {value ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ 🙂
+
+ )}
+
+ {editable && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/about/BannerUploader.tsx b/frontend/src/components/about/BannerUploader.tsx
new file mode 100644
index 0000000..a3c308c
--- /dev/null
+++ b/frontend/src/components/about/BannerUploader.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { ImagePlus } from "lucide-react";
+import { uploadImage } from "@/lib/about";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+
+interface BannerUploaderProps {
+ value: string;
+ onChange: (url: string) => void;
+ editable?: boolean;
+ className?: string;
+}
+
+/**
+ * 关于页顶部 banner 图片上传组件:
+ * - 默认显示图片;
+ * - 管理员 hover 时叠加「更换 banner」按钮;
+ * - 非管理员看到的是纯展示。
+ */
+export function BannerUploader({
+ value,
+ onChange,
+ editable = false,
+ className,
+}: BannerUploaderProps) {
+ const inputRef = useRef(null);
+ const [uploading, setUploading] = useState(false);
+
+ const handlePick = () => {
+ if (!editable || uploading) return;
+ inputRef.current?.click();
+ };
+
+ const handleFile = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ e.target.value = "";
+ if (!file) return;
+ setUploading(true);
+ try {
+ const url = await uploadImage(file);
+ onChange(url);
+ toast.success("Banner 已更新,记得保存设置~");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "上传失败");
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ return (
+
+ {value ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ 推荐尺寸 1200×450,hover 状态(管理员)可上传新图
+
+ )}
+
+ {editable && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/useIsAdmin.ts b/frontend/src/hooks/useIsAdmin.ts
new file mode 100644
index 0000000..de89055
--- /dev/null
+++ b/frontend/src/hooks/useIsAdmin.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { useAuthStore, useHydrated } from "@/stores/auth";
+
+/**
+ * 简单的「是否管理员」判断。
+ *
+ * 由于登录态只暴露了 userId / nickname / avatar / email 等基本信息,
+ * 没有角色字段。这里采用一个保守策略:
+ * - 已登录(token 存在)即视为可编辑;
+ * - 未登录视为访客。
+ *
+ * 设计上 blog-go-next 是单人博客(普通用户基本不会登录管理后台),
+ * 因此这个判断在实际场景中接近「只有站长能编辑」。
+ *
+ * 一旦后续登录接口暴露 roleLabel,可把这里的判断替换为真实角色判断。
+ *
+ * 实现上直接派生(derive)而非 useEffect + setState:token 是 Zustand
+ * 选择器拿到的响应式值,配合 useHydrated 保证 SSR / 水合前后的稳定渲染。
+ */
+export function useIsAdmin(): boolean {
+ const hydrated = useHydrated();
+ const token = useAuthStore((s) => s.token);
+ return hydrated && Boolean(token);
+}
\ No newline at end of file
diff --git a/frontend/src/lib/about.ts b/frontend/src/lib/about.ts
new file mode 100644
index 0000000..19c0524
--- /dev/null
+++ b/frontend/src/lib/about.ts
@@ -0,0 +1,102 @@
+import { api } from "@/lib/api";
+
+/** About Me 区域的一个要点(emoji + 文本) */
+export interface AboutPoint {
+ emoji: string;
+ text: string;
+}
+
+/** 技术栈 / 成就的徽章项(图标 emoji + 颜色 + 名称) */
+export interface AboutBadge {
+ name: string;
+ icon: string;
+ color: string;
+}
+
+/** 完整的「关于我」配置,由后端 /about 返回 */
+export interface AboutInfo {
+ id?: number;
+ banner: string;
+ avatar: string;
+ nickname: string;
+ pronouns: string;
+ intro: string;
+ subtitle: string;
+ bio: string;
+ aboutPoints: AboutPoint[];
+ email: string;
+ website: string;
+ github: string;
+ followers: number;
+ following: number;
+ techStack: AboutBadge[];
+ backendSkills: string;
+ frontendSkills: string;
+ otherSkills: string;
+ achievements: AboutBadge[];
+}
+
+/**
+ * 后端在 JSON 数组型字段上空值会返回 "[]"(字符串),与 AboutInfo 期望的数组不匹配。
+ * 这里统一在「读」路径上把字符串安全地转成数组,避免前端拿到结构断裂的数据。
+ */
+function ensureArray(value: unknown, fallback: T[]): T[] {
+ if (Array.isArray(value)) return value as T[];
+ if (typeof value === "string" && value) {
+ try {
+ const parsed = JSON.parse(value);
+ return Array.isArray(parsed) ? (parsed as T[]) : fallback;
+ } catch {
+ return fallback;
+ }
+ }
+ return fallback;
+}
+
+export async function getAbout(): Promise {
+ const res = await api.get>("/about");
+ const data = (res.data ?? {}) as Record;
+ return {
+ banner: typeof data.banner === "string" ? data.banner : "",
+ avatar: typeof data.avatar === "string" ? data.avatar : "",
+ nickname: typeof data.nickname === "string" ? data.nickname : "",
+ pronouns: typeof data.pronouns === "string" ? data.pronouns : "",
+ intro: typeof data.intro === "string" ? data.intro : "",
+ subtitle: typeof data.subtitle === "string" ? data.subtitle : "",
+ bio: typeof data.bio === "string" ? data.bio : "",
+ aboutPoints: ensureArray(data.aboutPoints, []),
+ email: typeof data.email === "string" ? data.email : "",
+ website: typeof data.website === "string" ? data.website : "",
+ github: typeof data.github === "string" ? data.github : "",
+ followers: typeof data.followers === "number" ? data.followers : 0,
+ following: typeof data.following === "number" ? data.following : 0,
+ techStack: ensureArray(data.techStack, []),
+ backendSkills: typeof data.backendSkills === "string" ? data.backendSkills : "",
+ frontendSkills: typeof data.frontendSkills === "string" ? data.frontendSkills : "",
+ otherSkills: typeof data.otherSkills === "string" ? data.otherSkills : "",
+ achievements: ensureArray(data.achievements, []),
+ };
+}
+
+export async function updateAbout(payload: AboutInfo): Promise {
+ // 把数组再序列化为 JSON 字符串,与后端 model 字段类型保持一致
+ const body = {
+ ...payload,
+ aboutPoints: JSON.stringify(payload.aboutPoints ?? []),
+ techStack: JSON.stringify(payload.techStack ?? []),
+ achievements: JSON.stringify(payload.achievements ?? []),
+ };
+ const res = await api.put("/admin/about", body);
+ if (!res.flag) {
+ throw new Error(res.message || "保存失败");
+ }
+}
+
+/** 上传图片,返回可访问的 URL。复用现有 admin 图片上传接口 */
+export async function uploadImage(file: File): Promise {
+ const res = await api.upload("/admin/articles/images", file);
+ if (!res.flag || !res.data) {
+ throw new Error(res.message || "上传失败");
+ }
+ return res.data;
+}
\ No newline at end of file