From 4a6c2147096b59a1f9bfc593e79b55741af8479b Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 30 Nov 2025 19:24:51 +0800 Subject: [PATCH] feat: notifications --- frontend/src/app.tsx | 2 + frontend/src/components/navigator.tsx | 54 ++++-- frontend/src/i18n.ts | 2 + frontend/src/network/network.ts | 20 +++ frontend/src/pages/notification_page.tsx | 209 +++++++++++++++++++++++ server/api/activity.go | 65 ++++++- server/dao/activity.go | 39 ++++- server/dao/resource.go | 11 ++ server/dao/user.go | 15 +- server/model/activity.go | 7 +- server/model/user.go | 21 +-- server/service/activity.go | 64 +++++++ server/service/comment.go | 12 +- server/service/user.go | 8 + 14 files changed, 492 insertions(+), 37 deletions(-) create mode 100644 frontend/src/pages/notification_page.tsx diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index f07bbb2..3ef98c0 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -19,6 +19,7 @@ import CreateCollectionPage from "./pages/create_collection_page.tsx"; import CollectionPage from "./pages/collection_page.tsx"; import { i18nData } from "./i18n.ts"; import { i18nContext } from "./utils/i18n.ts"; +import NotificationPage from "./pages/notification_page.tsx"; export default function App() { return ( @@ -49,6 +50,7 @@ export default function App() { element={} /> } /> + } /> diff --git a/frontend/src/components/navigator.tsx b/frontend/src/components/navigator.tsx index 8716054..96b20b4 100644 --- a/frontend/src/components/navigator.tsx +++ b/frontend/src/components/navigator.tsx @@ -2,11 +2,10 @@ import { app } from "../app.ts"; import { network } from "../network/network.ts"; import { useNavigate, useOutlet } from "react-router"; import { createContext, useContext, useEffect, useState } from "react"; -import { MdArrowUpward, MdOutlinePerson, MdSearch } from "react-icons/md"; +import { MdArrowUpward, MdOutlinePerson, MdSearch, MdNotifications } from "react-icons/md"; import { useTranslation } from "../utils/i18n"; import UploadingSideBar from "./uploading_side_bar.tsx"; import { ThemeSwitcher } from "./theme_switcher.tsx"; -import { IoLogoGithub } from "react-icons/io"; import { useAppContext } from "./AppContext.tsx"; import { AnimatePresence, motion } from "framer-motion"; @@ -234,16 +233,7 @@ export default function Navigator() { - - - + {app.isLoggedIn() && } {app.isLoggedIn() ? ( ) : ( @@ -554,3 +544,43 @@ function FloatingToTopButton() { ); } + +function NotificationButton() { + const [count, setCount] = useState(0); + const navigate = useNavigate(); + + useEffect(() => { + const fetchCount = async () => { + if (!app.isLoggedIn()) { + return; + } + const res = await network.getUserNotificationsCount(); + if (res.success && res.data !== undefined) { + setCount(res.data); + } + }; + + fetchCount(); + const interval = setInterval(fetchCount, 60000); // 每分钟请求一次 + + return () => clearInterval(interval); + }, []); + + return ( +
+ {count > 0 && ( + + {count > 99 ? "99+" : count} + + )} + +
+ ); +} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index d0dec47..b26902b 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -262,6 +262,7 @@ export const i18nData = { "Tag": "标签", "Optional": "可选", "Download": "下载", + "Notifications": "通知", }, }, "zh-TW": { @@ -527,6 +528,7 @@ export const i18nData = { "Tag": "標籤", "Optional": "可選", "Download": "下載", + "Notifications": "通知", }, }, }; diff --git a/frontend/src/network/network.ts b/frontend/src/network/network.ts index 327a214..164d237 100644 --- a/frontend/src/network/network.ts +++ b/frontend/src/network/network.ts @@ -730,6 +730,26 @@ class Network { ); } + async getUserNotifications(page: number = 1): Promise> { + return this._callApi(() => + axios.get(`${this.apiBaseUrl}/notification`, { + params: { page }, + }), + ); + } + + async resetUserNotificationsCount(): Promise> { + return this._callApi(() => + axios.post(`${this.apiBaseUrl}/notification/reset`), + ); + } + + async getUserNotificationsCount(): Promise> { + return this._callApi(() => + axios.get(`${this.apiBaseUrl}/notification/count`), + ); + } + async createCollection( title: string, article: string, diff --git a/frontend/src/pages/notification_page.tsx b/frontend/src/pages/notification_page.tsx new file mode 100644 index 0000000..155ede7 --- /dev/null +++ b/frontend/src/pages/notification_page.tsx @@ -0,0 +1,209 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Activity, ActivityType } from "../network/models.ts"; +import { network } from "../network/network.ts"; +import showToast from "../components/toast.ts"; +import { useTranslation } from "../utils/i18n"; +import { useNavigate } from "react-router"; +import Loading from "../components/loading.tsx"; +import { CommentContent } from "../components/comment_tile.tsx"; +import { MdOutlineArchive, MdOutlinePhotoAlbum } from "react-icons/md"; +import Badge from "../components/badge.tsx"; +import Markdown from "react-markdown"; +import { ErrorAlert } from "../components/alert.tsx"; +import { app } from "../app.ts"; + +export default function NotificationPage() { + const [activities, setActivities] = useState([]); + const pageRef = useRef(0); + const maxPageRef = useRef(1); + const isLoadingRef = useRef(false); + const { t } = useTranslation(); + + const fetchNextPage = useCallback(async () => { + if (isLoadingRef.current || pageRef.current >= maxPageRef.current) return; + isLoadingRef.current = true; + const response = await network.getUserNotifications(pageRef.current + 1); + if (response.success) { + setActivities((prev) => [...prev, ...response.data!]); + pageRef.current += 1; + maxPageRef.current = response.totalPages!; + } else { + showToast({ + type: "error", + message: response.message || "Failed to load activities", + }); + } + isLoadingRef.current = false; + }, []); + + useEffect(() => { + fetchNextPage(); + }, [fetchNextPage]); + + useEffect(() => { + network.resetUserNotificationsCount(); + }, []); + + useEffect(() => { + document.title = t("Notifications"); + }, []) + + useEffect(() => { + const handleScroll = () => { + if ( + window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - 100 && + !isLoadingRef.current && + pageRef.current < maxPageRef.current + ) { + fetchNextPage(); + } + }; + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, [fetchNextPage]); + + if (!app.user) { + return ( + + ); + } + + return ( +
+ {activities.map((activity) => ( + + ))} + {pageRef.current < maxPageRef.current && } +
+ ); +} + +function fileSizeToString(size: number) { + if (size < 1024) { + return size + "B"; + } else if (size < 1024 * 1024) { + return (size / 1024).toFixed(2) + "KB"; + } else if (size < 1024 * 1024 * 1024) { + return (size / 1024 / 1024).toFixed(2) + "MB"; + } else { + return (size / 1024 / 1024 / 1024).toFixed(2) + "GB"; + } +} + +function ActivityCard({ activity }: { activity: Activity }) { + const { t } = useTranslation(); + + const messages = [ + "Unknown activity", + t("Published a resource"), + t("Updated a resource"), + t("Posted a comment"), + t("Added a new file"), + ]; + + const navigate = useNavigate(); + + let content = <>; + + if ( + activity.type === ActivityType.ResourcePublished || + activity.type === ActivityType.ResourceUpdated + ) { + content = ( +
+
+ {activity.resource?.title} +
+ {activity.resource?.image && ( +
+ {activity.resource.title} +
+ )} +
+ ); + } else if (activity.type === ActivityType.NewComment) { + content = ( +
+ +
+ ); + } else if (activity.type === ActivityType.NewFile) { + content = ( +
+

+ {activity.file!.filename} +

+
+ + {activity.file!.description.replaceAll("\n", " \n")} + +
+

+ + + {activity.file!.is_redirect + ? t("Redirect") + : fileSizeToString(activity.file!.size)} + + + + {(() => { + let title = activity.resource!.title; + if (title.length > 20) { + title = title.slice(0, 20) + "..."; + } + return title; + })()} + +

+
+ ); + } + + return ( +
{ + if ( + activity.type === ActivityType.ResourcePublished || + activity.type === ActivityType.ResourceUpdated + ) { + navigate(`/resources/${activity.resource?.id}`); + } else if (activity.type === ActivityType.NewComment) { + navigate(`/comments/${activity.comment?.id}`); + } else if (activity.type === ActivityType.NewFile) { + navigate(`/resources/${activity.resource?.id}#files`); + } + }} + > +
+
+ {"avatar"} +
+ + {activity.user?.username} + + + {messages[activity.type]} + +
+ {content} +
+ ); +} diff --git a/server/api/activity.go b/server/api/activity.go index 1789fc2..e141697 100644 --- a/server/api/activity.go +++ b/server/api/activity.go @@ -1,10 +1,11 @@ package api import ( - "github.com/gofiber/fiber/v3" "nysoure/server/model" "nysoure/server/service" "strconv" + + "github.com/gofiber/fiber/v3" ) func handleGetActivity(c fiber.Ctx) error { @@ -28,6 +29,68 @@ func handleGetActivity(c fiber.Ctx) error { }) } +func handleGetUserNotifications(c fiber.Ctx) error { + uid, ok := c.Locals("uid").(uint) + if !ok { + return model.NewUnAuthorizedError("Unauthorized") + } + pageStr := c.Query("page", "1") + page, err := strconv.Atoi(pageStr) + if err != nil { + return model.NewRequestError("Invalid page number") + } + notifications, totalPages, err := service.GetUserNotifications(uid, page) + if err != nil { + return err + } + if notifications == nil { + notifications = []model.ActivityView{} + } + return c.JSON(model.PageResponse[model.ActivityView]{ + Success: true, + Data: notifications, + TotalPages: totalPages, + Message: "User notifications retrieved successfully", + }) +} + +func handleResetUserNotificationsCount(c fiber.Ctx) error { + uid, ok := c.Locals("uid").(uint) + if !ok { + return model.NewUnAuthorizedError("Unauthorized") + } + err := service.ResetUserNotificationsCount(uid) + if err != nil { + return err + } + return c.JSON(model.Response[any]{ + Success: true, + Message: "User notifications count reset successfully", + }) +} + +func handleGetUserNotificationsCount(c fiber.Ctx) error { + uid, ok := c.Locals("uid").(uint) + if !ok { + return model.NewUnAuthorizedError("Unauthorized") + } + count, err := service.GetUserNotificationsCount(uid) + if err != nil { + return err + } + return c.JSON(model.Response[uint]{ + Success: true, + Data: count, + Message: "User notifications count retrieved successfully", + }) +} + func AddActivityRoutes(router fiber.Router) { router.Get("/activity", handleGetActivity) + notificationrouter := router.Group("/notification") + { + notificationrouter.Get("/", handleGetUserNotifications) + notificationrouter.Post("/reset", handleResetUserNotificationsCount) + notificationrouter.Get("/count", handleGetUserNotificationsCount) + } } diff --git a/server/dao/activity.go b/server/dao/activity.go index 3a04831..98d7823 100644 --- a/server/dao/activity.go +++ b/server/dao/activity.go @@ -2,9 +2,10 @@ package dao import ( "errors" - "gorm.io/gorm" "nysoure/server/model" "time" + + "gorm.io/gorm" ) func AddNewResourceActivity(userID, resourceID uint) error { @@ -42,13 +43,20 @@ func AddUpdateResourceActivity(userID, resourceID uint) error { return db.Create(activity).Error } -func AddNewCommentActivity(userID, commentID uint) error { - activity := &model.Activity{ - UserID: userID, - Type: model.ActivityTypeNewComment, - RefID: commentID, - } - return db.Create(activity).Error +func AddNewCommentActivity(userID, commentID, notifyTo uint) error { + return db.Transaction(func(tx *gorm.DB) error { + activity := &model.Activity{ + UserID: userID, + Type: model.ActivityTypeNewComment, + RefID: commentID, + NotifyTo: notifyTo, + } + err := tx.Create(activity).Error + if err != nil { + return err + } + return tx.Model(&model.User{}).Where("id = ?", notifyTo).UpdateColumn("unread_notifications_count", gorm.Expr("unread_notifications_count + ?", 1)).Error + }) } func AddNewFileActivity(userID, fileID uint) error { @@ -82,3 +90,18 @@ func GetActivityList(offset, limit int) ([]model.Activity, int, error) { return activities, int(total), nil } + +func GetUserNotifications(userID uint, offset, limit int) ([]model.Activity, int, error) { + var activities []model.Activity + var total int64 + + if err := db.Model(&model.Activity{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db.Where("notify_to = ?", userID).Offset(offset).Limit(limit).Order("id DESC").Find(&activities).Error; err != nil { + return nil, 0, err + } + + return activities, int(total), nil +} diff --git a/server/dao/resource.go b/server/dao/resource.go index 82e3d10..660415a 100644 --- a/server/dao/resource.go +++ b/server/dao/resource.go @@ -693,3 +693,14 @@ func UpdateResourceImage(resourceID, oldImageID, newImageID uint) error { return nil }) } + +func GetResourceOwnerID(resourceID uint) (uint, error) { + var uid uint + if err := db.Model(&model.Resource{}).Select("user_id").Where("id = ?", resourceID).First(&uid).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, model.NewNotFoundError("Resource not found") + } + return 0, err + } + return uid, nil +} diff --git a/server/dao/user.go b/server/dao/user.go index b7c33d9..034f2d6 100644 --- a/server/dao/user.go +++ b/server/dao/user.go @@ -2,8 +2,9 @@ package dao import ( "errors" - "gorm.io/gorm" "nysoure/server/model" + + "gorm.io/gorm" ) func CreateUser(username string, hashedPassword []byte) (model.User, error) { @@ -132,3 +133,15 @@ func DeleteUser(id uint) error { } return db.Delete(&model.User{}, id).Error } + +func ResetUserNotificationsCount(userID uint) error { + return db.Model(&model.User{}).Where("id = ?", userID).Update("unread_notifications_count", 0).Error +} + +func GetUserNotificationCount(userID uint) (uint, error) { + var count uint + if err := db.Model(&model.User{}).Where("id = ?", userID).Select("unread_notifications_count").Scan(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/server/model/activity.go b/server/model/activity.go index 43294e4..b8b2d3e 100644 --- a/server/model/activity.go +++ b/server/model/activity.go @@ -18,9 +18,10 @@ const ( type Activity struct { gorm.Model - UserID uint `gorm:"not null"` - Type ActivityType `gorm:"not null;index:idx_type_refid"` - RefID uint `gorm:"not null;index:idx_type_refid"` + UserID uint `gorm:"not null"` + Type ActivityType `gorm:"not null;index:idx_type_refid"` + RefID uint `gorm:"not null;index:idx_type_refid"` + NotifyTo uint `gorm:"default:null;index"` } type ActivityView struct { diff --git a/server/model/user.go b/server/model/user.go index 8d9cf69..5e256dc 100644 --- a/server/model/user.go +++ b/server/model/user.go @@ -9,16 +9,17 @@ import ( type User struct { gorm.Model - Username string `gorm:"uniqueIndex;not null"` - PasswordHash []byte - IsAdmin bool - CanUpload bool - AvatarVersion int - ResourcesCount int - FilesCount int - CommentsCount int - Resources []Resource `gorm:"foreignKey:UserID"` - Bio string + Username string `gorm:"uniqueIndex;not null"` + PasswordHash []byte + IsAdmin bool + CanUpload bool + AvatarVersion int + ResourcesCount int + FilesCount int + CommentsCount int + Resources []Resource `gorm:"foreignKey:UserID"` + Bio string + UnreadNotificationsCount uint } type UserView struct { diff --git a/server/service/activity.go b/server/service/activity.go index 064a4e3..cb9f2f8 100644 --- a/server/service/activity.go +++ b/server/service/activity.go @@ -68,3 +68,67 @@ func GetActivityList(page int) ([]model.ActivityView, int, error) { return views, totalPages, nil } + +func GetUserNotifications(userID uint, page int) ([]model.ActivityView, int, error) { + offset := (page - 1) * pageSize + limit := pageSize + + activities, total, err := dao.GetUserNotifications(userID, offset, limit) + if err != nil { + return nil, 0, err + } + + var views []model.ActivityView + for _, activity := range activities { + user, err := dao.GetUserByID(activity.UserID) + if err != nil { + return nil, 0, err + } + var comment *model.CommentView + var resource *model.ResourceView + var file *model.FileView + switch activity.Type { + case model.ActivityTypeNewComment: + c, err := dao.GetCommentByID(activity.RefID) + if err != nil { + return nil, 0, err + } + comment = c.ToView() + comment.Content, comment.ContentTruncated = restrictCommentLength(c.Content) + case model.ActivityTypeNewResource, model.ActivityTypeUpdateResource: + r, err := dao.GetResourceByID(activity.RefID) + if err != nil { + return nil, 0, err + } + rv := r.ToView() + resource = &rv + case model.ActivityTypeNewFile: + f, err := dao.GetFileByID(activity.RefID) + if err != nil { + return nil, 0, err + } + fv := f.ToView() + file = fv + r, err := dao.GetResourceByID(f.ResourceID) + if err != nil { + return nil, 0, err + } + rv := r.ToView() + resource = &rv + } + view := model.ActivityView{ + ID: activity.ID, + User: user.ToView(), + Type: activity.Type, + Time: activity.CreatedAt, + Comment: comment, + Resource: resource, + File: file, + } + views = append(views, view) + } + + totalPages := (total + pageSize - 1) / pageSize + + return views, totalPages, nil +} diff --git a/server/service/comment.go b/server/service/comment.go index 86b4150..c63c28f 100644 --- a/server/service/comment.go +++ b/server/service/comment.go @@ -29,6 +29,8 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters") } + var notifyTo uint + switch cType { case model.CommentTypeResource: resourceExists, err := dao.ExistsResource(refID) @@ -39,12 +41,18 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType if !resourceExists { return nil, model.NewNotFoundError("Resource not found") } + notifyTo, err = dao.GetResourceOwnerID(refID) + if err != nil { + log.Error("Error getting resource owner ID:", err) + return nil, model.NewInternalServerError("Error getting resource owner ID") + } case model.CommentTypeReply: - _, err := dao.GetCommentByID(refID) + comment, err := dao.GetCommentByID(refID) if err != nil { log.Error("Error getting reply comment:", err) return nil, model.NewNotFoundError("Reply comment not found") } + notifyTo = comment.UserID } userExists, err := dao.ExistsUserByID(userID) @@ -63,7 +71,7 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType log.Error("Error creating comment:", err) return nil, model.NewInternalServerError("Error creating comment") } - err = dao.AddNewCommentActivity(userID, c.ID) + err = dao.AddNewCommentActivity(userID, c.ID, notifyTo) if err != nil { log.Error("Error creating comment activity:", err) } diff --git a/server/service/user.go b/server/service/user.go index ef7102e..2c19090 100644 --- a/server/service/user.go +++ b/server/service/user.go @@ -390,3 +390,11 @@ func validateUsername(username string) error { } return nil } + +func ResetUserNotificationsCount(userID uint) error { + return dao.ResetUserNotificationsCount(userID) +} + +func GetUserNotificationsCount(userID uint) (uint, error) { + return dao.GetUserNotificationCount(userID) +}