Merge branch 'test' of git.backyard.segmentfault.com:opensource/answer into test

This commit is contained in:
aichy126 2022-11-24 11:50:19 +08:00
commit ec0983395e
58 changed files with 1118 additions and 141 deletions

1
.gitignore vendored
View File

@ -20,6 +20,5 @@
Thumbs*.db
tmp
vendor/
.husky
/answer-data/
/answer

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd ui && pnpm commitlint --edit $1 --config commitlint.config.js

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd ui && npm run pre-commit

View File

@ -189,7 +189,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
activityCommon := activity_common2.NewActivityCommon(activityRepo)
activityActivityRepo := activity.NewActivityRepo(dataData)
commentCommonService := comment_common.NewCommentCommonService(commentCommonRepo)
activityService := activity2.NewActivityService(activityActivityRepo, userCommon, activityCommon, tagCommonService, objService, commentCommonService, revisionService)
activityService := activity2.NewActivityService(activityActivityRepo, userCommon, activityCommon, tagCommonService, objService, commentCommonService, revisionService, metaService)
activityController := controller.NewActivityController(activityCommon, activityService)
answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, controller_backyardReportController, userBackyardController, reasonController, themeController, siteInfoController, siteinfoController, notificationController, dashboardController, uploadController, activityController)
swaggerRouter := router.NewSwaggerRouter(swaggerConf)

View File

@ -395,6 +395,7 @@ ui:
tag_info:
created_at: Created
edited_at: Edited
history: History
synonyms:
title: Synonyms
text: The following tags will be remapped to
@ -512,6 +513,11 @@ ui:
label: Answer
msg:
empty: Answer cannot be empty.
edit_summary:
label: Edit Summary
placeholder: >-
Briefly explain your changes (corrected spelling, fixed grammar,
improved formatting)
btn_post_question: Post your question
btn_save_edits: Save edits
answer_question: Answer your own question
@ -532,6 +538,7 @@ ui:
setting: Settings
logout: Log out
admin: Admin
review: Review
search:
placeholder: Search
footer:
@ -741,6 +748,9 @@ ui:
logout: Log out
verify: Verify
add_question: Add question
approve: Approve
reject: Reject
skip: Skip
search:
title: Search Results
keywords: Keywords
@ -1189,3 +1199,36 @@ ui:
invalid: is invalid
btn_submit: Save
not_found_props: "Required property {{ key }} not found."
page_review:
review: Reivew
proposed: proposed
question_edit: Question edit
answer_edit: Answer edit
tag_edit: Tag edit
edit_summary: Edit summary
empty: No review tasks left.
timeline:
undeleted: undeleted
deleted: deleted
downvote: downvote
upvote: upvote
accept: accept
cancelled: cancelled
commented: commented
rollback: rollback
edited: edited
answered: answered
asked: asked
closed: closed
reopened: reopened
created: created
title: "History for"
show_votes: "Show votes"
n_or_a: N/A
title_for: "Timeline for"
title_for_tag: "Title for tag"
datetime: Datetime
type: Type
by: By
comment: Comment
no_data: "We couldn't find anything."

View File

@ -1,9 +1,21 @@
package constant
// question activity
type ActivityTypeKey string
const (
ActEdited = "edited"
ActClosed = "closed"
ActVotedDown = "voted_down"
ActVotedUp = "voted_up"
ActVoteDown = "vote_down"
ActVoteUp = "vote_up"
ActUpVote = "upvote"
ActDownVote = "downvote"
ActFollow = "follow"
ActAccepted = "accepted"
ActAccept = "accept"
)
const (
ActQuestionAsked ActivityTypeKey = "question.asked"
ActQuestionClosed ActivityTypeKey = "question.closed"
@ -12,29 +24,25 @@ const (
ActQuestionCommented ActivityTypeKey = "question.commented"
ActQuestionAccept ActivityTypeKey = "question.accept"
ActQuestionUpvote ActivityTypeKey = "question.upvote"
ActQuestionDownvote ActivityTypeKey = "question.downvote"
ActQuestionDownVote ActivityTypeKey = "question.downvote"
ActQuestionEdited ActivityTypeKey = "question.edited"
ActQuestionRollback ActivityTypeKey = "question.rollback"
ActQuestionDeleted ActivityTypeKey = "question.deleted"
ActQuestionUndeleted ActivityTypeKey = "question.undeleted"
)
// answer activity
const (
ActAnswerAnswered ActivityTypeKey = "answer.answered"
ActAnswerCommented ActivityTypeKey = "answer.commented"
ActAnswerAccept ActivityTypeKey = "answer.accept"
ActAnswerUpvote ActivityTypeKey = "answer.upvote"
ActAnswerDownvote ActivityTypeKey = "answer.downvote"
ActAnswerDownVote ActivityTypeKey = "answer.downvote"
ActAnswerEdited ActivityTypeKey = "answer.edited"
ActAnswerRollback ActivityTypeKey = "answer.rollback"
ActAnswerDeleted ActivityTypeKey = "answer.deleted"
ActAnswerUndeleted ActivityTypeKey = "answer.undeleted"
)
// tag activity
const (
ActTagCreated ActivityTypeKey = "tag.created"
ActTagEdited ActivityTypeKey = "tag.edited"

View File

@ -39,6 +39,9 @@ func (ac *ActivityController) GetObjectTimeline(ctx *gin.Context) {
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
if userInfo := middleware.GetUserInfoFromContext(ctx); userInfo != nil {
req.IsAdmin = userInfo.IsAdmin
}
resp, err := ac.activityService.GetObjectTimeline(ctx, req)
handler.HandleResponse(ctx, err, resp)

View File

@ -6,7 +6,6 @@ import (
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/middleware"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service"
"github.com/answerdev/answer/internal/service/dashboard"
@ -243,10 +242,13 @@ func (ac *AnswerController) Adopted(ctx *gin.Context) {
// @Router /answer/admin/api/answer/status [put]
// @Success 200 {object} handler.RespBody
func (ac *AnswerController) AdminSetAnswerStatus(ctx *gin.Context) {
req := &entity.AdminSetAnswerStatusRequest{}
req := &schema.AdminSetAnswerStatusRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
err := ac.answerService.AdminSetAnswerStatus(ctx, req.AnswerID, req.StatusStr)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := ac.answerService.AdminSetAnswerStatus(ctx, req)
handler.HandleResponse(ctx, err, gin.H{})
}

View File

@ -69,7 +69,7 @@ func (tc *TagController) RemoveTag(ctx *gin.Context) {
return
}
err := tc.tagService.RemoveTag(ctx, req.TagID)
err := tc.tagService.RemoveTag(ctx, req)
handler.HandleResponse(ctx, err, nil)
}

View File

@ -48,11 +48,6 @@ type CmsAnswerSearch struct {
QuestionID string `validate:"omitempty,gt=0,lte=24" json:"question_id" form:"question_id" ` //Query string
}
type AdminSetAnswerStatusRequest struct {
StatusStr string `json:"status" form:"status"`
AnswerID string `json:"answer_id" form:"answer_id"`
}
// TableName answer table name
func (Answer) TableName() string {
return "answer"

View File

@ -62,11 +62,12 @@ type QuestionWithTagsRevision struct {
// TagSimpleInfoForRevision tag simple info for revision
type TagSimpleInfoForRevision struct {
ID string `xorm:"not null pk comment('tag_id') BIGINT(20) id"`
MainTagID int64 `xorm:"not null default 0 BIGINT(20) main_tag_id"`
SlugName string `xorm:"not null default '' unique VARCHAR(35) slug_name"`
DisplayName string `xorm:"not null default '' VARCHAR(35) display_name"`
Recommend bool `xorm:"not null default false BOOL recommend"`
Reserved bool `xorm:"not null default false BOOL reserved"`
RevisionID string `xorm:"not null default 0 BIGINT(20) revision_id"`
ID string `xorm:"not null pk comment('tag_id') BIGINT(20) id"`
MainTagID int64 `xorm:"not null default 0 BIGINT(20) main_tag_id"`
MainTagSlugName string `xorm:"not null default '' VARCHAR(35) main_tag_slug_name"`
SlugName string `xorm:"not null default '' unique VARCHAR(35) slug_name"`
DisplayName string `xorm:"not null default '' VARCHAR(35) display_name"`
Recommend bool `xorm:"not null default false BOOL recommend"`
Reserved bool `xorm:"not null default false BOOL reserved"`
RevisionID string `xorm:"not null default 0 BIGINT(20) revision_id"`
}

View File

@ -3,10 +3,46 @@ package migrations
import (
"time"
"github.com/answerdev/answer/internal/entity"
"xorm.io/xorm"
)
func addActivityTimeline(x *xorm.Engine) error {
defaultConfigTable := []*entity.Config{
{ID: 87, Key: "question.asked", Value: `0`},
{ID: 88, Key: "question.closed", Value: `0`},
{ID: 89, Key: "question.reopened", Value: `0`},
{ID: 90, Key: "question.answered", Value: `0`},
{ID: 91, Key: "question.commented", Value: `0`},
{ID: 92, Key: "question.accept", Value: `0`},
{ID: 93, Key: "question.edited", Value: `0`},
{ID: 94, Key: "question.rollback", Value: `0`},
{ID: 95, Key: "question.deleted", Value: `0`},
{ID: 96, Key: "question.undeleted", Value: `0`},
{ID: 97, Key: "answer.answered", Value: `0`},
{ID: 98, Key: "answer.commented", Value: `0`},
{ID: 99, Key: "answer.edited", Value: `0`},
{ID: 100, Key: "answer.rollback", Value: `0`},
{ID: 101, Key: "answer.undeleted", Value: `0`},
{ID: 102, Key: "tag.created", Value: `0`},
{ID: 103, Key: "tag.edited", Value: `0`},
{ID: 104, Key: "tag.rollback", Value: `0`},
{ID: 105, Key: "tag.deleted", Value: `0`},
{ID: 106, Key: "tag.undeleted", Value: `0`},
}
for _, c := range defaultConfigTable {
exist, err := x.Get(&entity.Config{ID: c.ID, Key: c.Key})
if err != nil {
return err
}
if exist {
continue
}
if _, err := x.Insert(&entity.Config{ID: c.ID, Key: c.Key, Value: c.Value}); err != nil {
return err
}
}
type Revision struct {
ReviewUserID int64 `xorm:"not null default 0 BIGINT(20) review_user_id"`
}

View File

@ -2,10 +2,13 @@ package activity
import (
"context"
"fmt"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/repo/config"
"github.com/answerdev/answer/internal/service/activity"
"github.com/segmentfault/pacman/errors"
)
@ -27,7 +30,23 @@ func NewActivityRepo(
func (ar *activityRepo) GetObjectAllActivity(ctx context.Context, objectID string, showVote bool) (
activityList []*entity.Activity, err error) {
activityList = make([]*entity.Activity, 0)
err = ar.data.DB.Find(&activityList, &entity.Activity{OriginalObjectID: objectID})
session := ar.data.DB.Desc("created_at")
if !showVote {
var activityTypeNotShown []int
for _, obj := range []string{constant.AnswerObjectType, constant.QuestionObjectType, constant.CommentObjectType} {
for _, act := range []string{
constant.ActVotedDown,
constant.ActVotedUp,
constant.ActVoteDown,
constant.ActVoteUp,
} {
activityTypeNotShown = append(activityTypeNotShown, config.Key2IDMapping[fmt.Sprintf("%s.%s", obj, act)])
}
}
session.NotIn("activity_type", activityTypeNotShown)
}
err = session.Find(&activityList, &entity.Activity{OriginalObjectID: objectID})
if err != nil {
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}

View File

@ -144,9 +144,11 @@ func (ar *AnswerActivityRepo) AcceptAnswer(ctx context.Context,
if action == acceptAction {
addActivity.UserID = questionUserID
addActivity.TriggerUserID = converter.StringToInt64(answerUserID)
addActivity.OriginalObjectID = questionObjID // if activity is 'accept' means this question is accept the answer.
} else {
addActivity.UserID = answerUserID
addActivity.TriggerUserID = converter.StringToInt64(answerUserID)
addActivity.OriginalObjectID = answerObjID // if activity is 'accepted' means this answer was accepted.
}
if isSelf {
addActivity.Rank = 0
@ -234,16 +236,17 @@ func (ar *AnswerActivityRepo) CancelAcceptAnswer(ctx context.Context,
return errors.InternalServer(reason.DatabaseError).WithError(e).WithStack()
}
addActivity := &entity.Activity{
ObjectID: answerObjID,
OriginalObjectID: questionObjID,
ActivityType: activityType,
Rank: -deltaRank,
HasRank: hasRank,
ObjectID: answerObjID,
ActivityType: activityType,
Rank: -deltaRank,
HasRank: hasRank,
}
if action == acceptAction {
addActivity.UserID = questionUserID
addActivity.OriginalObjectID = questionObjID
} else {
addActivity.UserID = answerUserID
addActivity.OriginalObjectID = answerObjID
}
addActivityList = append(addActivityList, addActivity)
}

View File

@ -54,7 +54,7 @@ func Test_tagRepo_GetTagByID(t *testing.T) {
tagOnce.Do(addTagList)
tagCommonRepo := tag_common.NewTagCommonRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
gotTag, exist, err := tagCommonRepo.GetTagByID(context.TODO(), testTagList[0].ID)
gotTag, exist, err := tagCommonRepo.GetTagByID(context.TODO(), testTagList[0].ID, true)
assert.NoError(t, err)
assert.True(t, exist)
assert.Equal(t, testTagList[0].SlugName, gotTag.SlugName)
@ -139,7 +139,7 @@ func Test_tagRepo_UpdateTag(t *testing.T) {
tagCommonRepo := tag_common.NewTagCommonRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
gotTag, exist, err := tagCommonRepo.GetTagByID(context.TODO(), testTagList[0].ID)
gotTag, exist, err := tagCommonRepo.GetTagByID(context.TODO(), testTagList[0].ID, true)
assert.NoError(t, err)
assert.True(t, exist)
assert.Equal(t, testTagList[0].DisplayName, gotTag.DisplayName)
@ -152,7 +152,7 @@ func Test_tagRepo_UpdateTagQuestionCount(t *testing.T) {
err := tagCommonRepo.UpdateTagQuestionCount(context.TODO(), testTagList[0].ID, 100)
assert.NoError(t, err)
gotTag, exist, err := tagCommonRepo.GetTagByID(context.TODO(), testTagList[0].ID)
gotTag, exist, err := tagCommonRepo.GetTagByID(context.TODO(), testTagList[0].ID, true)
assert.NoError(t, err)
assert.True(t, exist)
assert.Equal(t, 100, gotTag.QuestionCount)
@ -172,7 +172,7 @@ func Test_tagRepo_UpdateTagSynonym(t *testing.T) {
tagCommonRepo := tag_common.NewTagCommonRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
gotTag, exist, err := tagCommonRepo.GetTagByID(context.TODO(), testTagList[2].ID)
gotTag, exist, err := tagCommonRepo.GetTagByID(context.TODO(), testTagList[2].ID, true)
assert.NoError(t, err)
assert.True(t, exist)
assert.Equal(t, testTagList[0].ID, fmt.Sprintf("%d", gotTag.MainTagID))

View File

@ -122,12 +122,14 @@ func (tr *tagCommonRepo) GetTagListByNames(ctx context.Context, names []string)
}
// GetTagByID get tag one
func (tr *tagCommonRepo) GetTagByID(ctx context.Context, tagID string) (
func (tr *tagCommonRepo) GetTagByID(ctx context.Context, tagID string, includeDeleted bool) (
tag *entity.Tag, exist bool, err error,
) {
tag = &entity.Tag{}
session := tr.data.DB.Where(builder.Eq{"id": tagID})
session.Where(builder.Eq{"status": entity.TagStatusAvailable})
if !includeDeleted {
session.Where(builder.Eq{"status": entity.TagStatusAvailable})
}
exist, err = session.Get(tag)
if err != nil {
return nil, false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()

View File

@ -17,6 +17,7 @@ type GetObjectTimelineReq struct {
ObjectID string `validate:"omitempty,gt=0,lte=100" form:"object_id"`
ShowVote bool `validate:"omitempty" form:"show_vote"`
UserID string `json:"-"`
IsAdmin bool `json:"-"`
}
// GetObjectTimelineResp get object timeline response
@ -38,14 +39,16 @@ type ActObjectTimeline struct {
ObjectType string `json:"object_type"`
Cancelled bool `json:"cancelled"`
CancelledAt int64 `json:"cancelled_at"`
UserID string `json:"-"`
}
// ActObjectInfo act object info
type ActObjectInfo struct {
Title string `json:"title"`
ObjectType string `json:"object_type"`
QuestionID string `json:"question_id"`
AnswerID string `json:"answer_id"`
ObjectType string `json:"object_type"`
Title string `json:"title"`
QuestionID string `json:"question_id"`
AnswerID string `json:"answer_id"`
MainTagSlugName string `json:"main_tag_slug_name"`
}
// GetObjectTimelineDetailReq get object timeline detail request
@ -63,7 +66,18 @@ type GetObjectTimelineDetailResp struct {
// ObjectTimelineDetail object timeline detail
type ObjectTimelineDetail struct {
Title string `json:"title"`
Tags []string `json:"tags"`
OriginalText string `json:"original_text"`
Title string `json:"title"`
Tags []*ObjectTimelineTag `json:"tags"`
OriginalText string `json:"original_text"`
SlugName string `json:"slug_name"`
MainTagSlugName string `json:"main_tag_slug_name"`
}
// ObjectTimelineTag object timeline tags
type ObjectTimelineTag struct {
SlugName string `json:"slug_name"`
DisplayName string `json:"display_name"`
MainTagSlugName string `json:"main_tag_slug_name"`
Recommend bool `json:"recommend"`
Reserved bool `json:"reserved"`
}

View File

@ -77,7 +77,13 @@ type AdminAnswerInfo struct {
}
type AnswerAdoptedReq struct {
QuestionID string `json:"question_id" ` // question_id
AnswerID string `json:"answer_id" `
QuestionID string `json:"question_id"`
AnswerID string `json:"answer_id"`
UserID string `json:"-" `
}
type AdminSetAnswerStatusRequest struct {
StatusStr string `json:"status"`
AnswerID string `json:"answer_id"`
UserID string `json:"-" `
}

View File

@ -11,11 +11,13 @@ import (
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/activity_common"
"github.com/answerdev/answer/internal/service/comment_common"
"github.com/answerdev/answer/internal/service/meta"
"github.com/answerdev/answer/internal/service/object_info"
"github.com/answerdev/answer/internal/service/revision_common"
"github.com/answerdev/answer/internal/service/tag_common"
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/answerdev/answer/pkg/converter"
"github.com/answerdev/answer/pkg/obj"
"github.com/segmentfault/pacman/log"
)
@ -33,6 +35,7 @@ type ActivityService struct {
objectInfoService *object_info.ObjService
commentCommonService *comment_common.CommentCommonService
revisionService *revision_common.RevisionService
metaService *meta.MetaService
}
// NewActivityService new activity service
@ -44,6 +47,7 @@ func NewActivityService(
objectInfoService *object_info.ObjService,
commentCommonService *comment_common.CommentCommonService,
revisionService *revision_common.RevisionService,
metaService *meta.MetaService,
) *ActivityService {
return &ActivityService{
objectInfoService: objectInfoService,
@ -53,6 +57,7 @@ func NewActivityService(
tagCommonService: tagCommonService,
commentCommonService: commentCommonService,
revisionService: revisionService,
metaService: metaService,
}
}
@ -64,14 +69,10 @@ func (as *ActivityService) GetObjectTimeline(ctx context.Context, req *schema.Ge
Timeline: make([]*schema.ActObjectTimeline, 0),
}
objInfo, err := as.objectInfoService.GetInfo(ctx, req.ObjectID)
resp.ObjectInfo, err = as.getTimelineMainObjInfo(ctx, req.ObjectID)
if err != nil {
return nil, err
}
resp.ObjectInfo.Title = objInfo.Title
resp.ObjectInfo.ObjectType = objInfo.ObjectType
resp.ObjectInfo.QuestionID = objInfo.QuestionID
resp.ObjectInfo.AnswerID = objInfo.AnswerID
activityList, err := as.activityRepo.GetObjectAllActivity(ctx, req.ObjectID, req.ShowVote)
if err != nil {
@ -85,67 +86,144 @@ func (as *ActivityService) GetObjectTimeline(ctx context.Context, req *schema.Ge
Cancelled: act.Cancelled == entity.ActivityCancelled,
ObjectID: act.ObjectID,
}
item.ObjectType, _ = obj.GetObjectTypeStrByObjectID(act.ObjectID)
if item.Cancelled {
item.CancelledAt = act.CancelledAt.Unix()
}
// database save activity type is number, change to activity type string is like "question.asked".
// so we need to cut the front part of '.'
item.ObjectType, item.ActivityType, _ = strings.Cut(config.ID2KeyMapping[act.ActivityType], ".")
isHidden, formattedActivityType := formatActivity(item.ActivityType)
if isHidden {
_, item.ActivityType, _ = strings.Cut(config.ID2KeyMapping[act.ActivityType], ".")
// format activity type string to show
if isHidden, formattedActivityType := formatActivity(item.ActivityType); isHidden {
continue
}
item.ActivityType = formattedActivityType
// get user info
userBasicInfo, exist, err := as.userCommon.GetUserBasicInfoByID(ctx, act.UserID)
if err != nil {
return nil, err
}
if exist {
item.Username = userBasicInfo.Username
item.UserDisplayName = userBasicInfo.DisplayName
} else {
item.ActivityType = formattedActivityType
}
if item.ObjectType == constant.CommentObjectType {
comment, err := as.commentCommonService.GetComment(ctx, item.ObjectID)
if err != nil {
log.Error(err)
} else {
item.Comment = comment.ParsedText
}
// if activity is down vote, only admin can see who does it.
if item.ActivityType == constant.ActDownVote && !req.IsAdmin {
item.Username = "N/A"
item.UserDisplayName = "N/A"
} else {
item.UserID = act.UserID
}
item.Comment = as.getTimelineActivityComment(ctx, item.ObjectID, item.ObjectType, item.ActivityType, item.RevisionID)
resp.Timeline = append(resp.Timeline, item)
}
as.formatTimelineUserInfo(ctx, resp.Timeline)
return
}
func (as *ActivityService) getTimelineMainObjInfo(ctx context.Context, objectID string) (
resp *schema.ActObjectInfo, err error) {
resp = &schema.ActObjectInfo{}
objInfo, err := as.objectInfoService.GetInfo(ctx, objectID)
if err != nil {
return nil, err
}
resp.Title = objInfo.Title
if objInfo.ObjectType == constant.TagObjectType {
tag, exist, _ := as.tagCommonService.GetTagByID(ctx, objInfo.TagID)
if exist {
resp.Title = tag.SlugName
resp.MainTagSlugName = tag.MainTagSlugName
}
}
resp.ObjectType = objInfo.ObjectType
resp.QuestionID = objInfo.QuestionID
resp.AnswerID = objInfo.AnswerID
return resp, nil
}
func (as *ActivityService) getTimelineActivityComment(ctx context.Context, objectID, objectType,
activityType, revisionID string) (comment string) {
if objectType == constant.CommentObjectType {
commentInfo, err := as.commentCommonService.GetComment(ctx, objectID)
if err != nil {
log.Error(err)
} else {
return commentInfo.ParsedText
}
return
}
if activityType == constant.ActEdited {
revision, err := as.revisionService.GetRevision(ctx, revisionID)
if err != nil {
log.Error(err)
} else {
return revision.Log
}
return
}
if activityType == constant.ActClosed {
// only question can be closed
metaInfo, err := as.metaService.GetMetaByObjectIdAndKey(ctx, objectID, entity.QuestionCloseReasonKey)
if err != nil {
log.Error(err)
} else {
closeMsg := &schema.CloseQuestionMeta{}
if err := json.Unmarshal([]byte(metaInfo.Value), closeMsg); err == nil {
return closeMsg.CloseMsg
}
}
}
return ""
}
func (as *ActivityService) formatTimelineUserInfo(ctx context.Context, timeline []*schema.ActObjectTimeline) {
userExist := make(map[string]bool)
userIDs := make([]string, 0)
for _, info := range timeline {
if len(info.UserID) == 0 || userExist[info.UserID] {
continue
}
userIDs = append(userIDs, info.UserID)
}
if len(userIDs) == 0 {
return
}
userInfoMapping, err := as.userCommon.BatchUserBasicInfoByID(ctx, userIDs)
if err != nil {
log.Error(err)
return
}
for _, info := range timeline {
if len(info.UserID) == 0 {
continue
}
if userInfo, ok := userInfoMapping[info.UserID]; ok {
info.Username = userInfo.Username
info.UserDisplayName = userInfo.DisplayName
}
}
}
// GetObjectTimelineDetail get object timeline
func (as *ActivityService) GetObjectTimelineDetail(ctx context.Context, req *schema.GetObjectTimelineDetailReq) (
resp *schema.GetObjectTimelineDetailResp, err error) {
resp = &schema.GetObjectTimelineDetailResp{}
resp.OldRevision, err = as.getOneObjectDetail(ctx, req.OldRevisionID)
if err != nil {
return nil, err
}
resp.NewRevision, err = as.getOneObjectDetail(ctx, req.NewRevisionID)
if err != nil {
return nil, err
}
resp.OldRevision, _ = as.getOneObjectDetail(ctx, req.OldRevisionID)
resp.NewRevision, _ = as.getOneObjectDetail(ctx, req.NewRevisionID)
return resp, nil
}
// GetObjectTimelineDetail get object detail
func (as *ActivityService) getOneObjectDetail(ctx context.Context, revisionID string) (
resp *schema.ObjectTimelineDetail, err error) {
resp = &schema.ObjectTimelineDetail{Tags: make([]string, 0)}
resp = &schema.ObjectTimelineDetail{Tags: make([]*schema.ObjectTimelineTag, 0)}
// if request revision is 0, return null object detail.
if revisionID == "0" {
return nil, nil
}
revision, err := as.revisionService.GetRevision(ctx, revisionID)
if err != nil {
return nil, err
log.Warn(err)
return nil, nil
}
objInfo, err := as.objectInfoService.GetInfo(ctx, revision.ObjectID)
if err != nil {
@ -160,7 +238,13 @@ func (as *ActivityService) getOneObjectDetail(ctx context.Context, revisionID st
return resp, nil
}
for _, tag := range data.Tags {
resp.Tags = append(resp.Tags, tag.SlugName)
resp.Tags = append(resp.Tags, &schema.ObjectTimelineTag{
SlugName: tag.SlugName,
DisplayName: tag.DisplayName,
MainTagSlugName: tag.MainTagSlugName,
Recommend: tag.Recommend,
Reserved: tag.Reserved,
})
}
resp.Title = data.Title
resp.OriginalText = data.OriginalText
@ -178,8 +262,10 @@ func (as *ActivityService) getOneObjectDetail(ctx context.Context, revisionID st
log.Errorf("revision parsing error %s", err)
return resp, nil
}
resp.Title = data.SlugName
resp.Title = data.DisplayName
resp.OriginalText = data.OriginalText
resp.SlugName = data.SlugName
resp.MainTagSlugName = data.MainTagSlugName
default:
log.Errorf("unknown object type %s", objInfo.ObjectType)
}
@ -187,14 +273,19 @@ func (as *ActivityService) getOneObjectDetail(ctx context.Context, revisionID st
}
func formatActivity(activityType string) (isHidden bool, formattedActivityType string) {
if activityType == "voted_up" || activityType == "voted_down" || activityType == "accepted" {
if activityType == constant.ActVotedUp ||
activityType == constant.ActVotedDown ||
activityType == constant.ActFollow {
return true, ""
}
if activityType == "vote_up" {
return false, "upvote"
if activityType == constant.ActVoteUp {
return false, constant.ActUpVote
}
if activityType == "vote_down" {
return false, "downvote"
if activityType == constant.ActVoteDown {
return false, constant.ActDownVote
}
if activityType == constant.ActAccepted {
return false, constant.ActAccept
}
return false, activityType
}

View File

@ -7,19 +7,18 @@ import (
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/service/activity"
"github.com/answerdev/answer/internal/service/activity_common"
"github.com/answerdev/answer/internal/service/activity_queue"
"github.com/answerdev/answer/internal/service/notice_queue"
"github.com/answerdev/answer/internal/service/revision_common"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/activity"
"github.com/answerdev/answer/internal/service/activity_common"
"github.com/answerdev/answer/internal/service/activity_queue"
answercommon "github.com/answerdev/answer/internal/service/answer_common"
collectioncommon "github.com/answerdev/answer/internal/service/collection_common"
"github.com/answerdev/answer/internal/service/notice_queue"
"github.com/answerdev/answer/internal/service/permission"
questioncommon "github.com/answerdev/answer/internal/service/question_common"
"github.com/answerdev/answer/internal/service/revision_common"
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
@ -118,6 +117,12 @@ func (as *AnswerService) RemoveAnswer(ctx context.Context, req *schema.RemoveAns
if err != nil {
log.Errorf("delete answer activity change failed: %s", err.Error())
}
activity_queue.AddActivity(&schema.ActivityMsg{
UserID: req.UserID,
ObjectID: answerInfo.ID,
OriginalObjectID: answerInfo.ID,
ActivityTypeKey: constant.ActAnswerDeleted,
})
return
}
@ -367,12 +372,12 @@ func (as *AnswerService) Get(ctx context.Context, answerID, loginUserID string)
return info, questionInfo, has, nil
}
func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, answerID string, setStatusStr string) error {
setStatus, ok := entity.CmsAnswerSearchStatus[setStatusStr]
func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.AdminSetAnswerStatusRequest) error {
setStatus, ok := entity.CmsAnswerSearchStatus[req.StatusStr]
if !ok {
return fmt.Errorf("question status does not exist")
}
answerInfo, exist, err := as.answerRepo.GetAnswer(ctx, answerID)
answerInfo, exist, err := as.answerRepo.GetAnswer(ctx, req.AnswerID)
if err != nil {
return err
}
@ -389,6 +394,13 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, answerID stri
err = as.answerActivityService.DeleteAnswer(ctx, answerInfo.ID, answerInfo.CreatedAt, answerInfo.VoteCount)
if err != nil {
log.Errorf("admin delete question then rank rollback error %s", err.Error())
} else {
activity_queue.AddActivity(&schema.ActivityMsg{
UserID: req.UserID,
ObjectID: answerInfo.ID,
OriginalObjectID: answerInfo.ID,
ActivityTypeKey: constant.ActAnswerDeleted,
})
}
}

View File

@ -150,12 +150,19 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment
resp.UserStatus = userInfo.Status
}
activity_queue.AddActivity(&schema.ActivityMsg{
activityMsg := &schema.ActivityMsg{
UserID: comment.UserID,
ObjectID: comment.ID,
OriginalObjectID: req.ObjectID,
ActivityTypeKey: constant.ActQuestionCommented,
})
}
switch objInfo.ObjectType {
case constant.QuestionObjectType:
activityMsg.ActivityTypeKey = constant.ActQuestionCommented
case constant.AnswerObjectType:
activityMsg.ActivityTypeKey = constant.ActAnswerCommented
}
activity_queue.AddActivity(activityMsg)
return resp, nil
}

View File

@ -95,7 +95,7 @@ func (os *ObjService) GetUnreviewedRevisionInfo(ctx context.Context, objectID st
}
case constant.TagObjectType:
tagInfo, exist, err := os.tagRepo.GetTagByID(ctx, objectID)
tagInfo, exist, err := os.tagRepo.GetTagByID(ctx, objectID, true)
if err != nil {
return nil, err
}
@ -192,7 +192,7 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc
}
}
case constant.TagObjectType:
tagInfo, exist, err := os.tagRepo.GetTagByID(ctx, objectID)
tagInfo, exist, err := os.tagRepo.GetTagByID(ctx, objectID, true)
if err != nil {
return nil, err
}

View File

@ -710,13 +710,19 @@ func (qs *QuestionService) AdminSetQuestionStatus(ctx context.Context, questionI
if err != nil {
log.Errorf("admin delete question then rank rollback error %s", err.Error())
}
activity_queue.AddActivity(&schema.ActivityMsg{
UserID: questionInfo.UserID,
ObjectID: questionInfo.ID,
OriginalObjectID: questionInfo.ID,
ActivityTypeKey: constant.ActQuestionDeleted,
})
}
if setStatus == entity.QuestionStatusAvailable && questionInfo.Status == entity.QuestionStatusClosed {
activity_queue.AddActivity(&schema.ActivityMsg{
UserID: questionInfo.UserID,
ObjectID: questionInfo.ID,
OriginalObjectID: questionInfo.ID,
ActivityTypeKey: constant.ActQuestionDeleted,
ActivityTypeKey: constant.ActQuestionReopened,
})
}
if setStatus == entity.QuestionStatusClosed && questionInfo.Status != entity.QuestionStatusClosed {

View File

@ -7,6 +7,7 @@ import (
"github.com/answerdev/answer/internal/service/revision"
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema"
@ -47,6 +48,7 @@ func (rs *RevisionService) GetRevision(ctx context.Context, revisionID string) (
revision *entity.Revision, err error) {
revisionInfo, exist, err := rs.revisionRepo.GetRevisionByID(ctx, revisionID)
if err != nil {
log.Error(err)
return nil, err
}
if !exist {

View File

@ -56,13 +56,17 @@ func NewTagService(
}
// RemoveTag delete tag
func (ts *TagService) RemoveTag(ctx context.Context, tagID string) (err error) {
// TODO permission
err = ts.tagRepo.RemoveTag(ctx, tagID)
func (ts *TagService) RemoveTag(ctx context.Context, req *schema.RemoveTagReq) (err error) {
err = ts.tagRepo.RemoveTag(ctx, req.TagID)
if err != nil {
return err
}
activity_queue.AddActivity(&schema.ActivityMsg{
UserID: req.UserID,
ObjectID: req.TagID,
OriginalObjectID: req.TagID,
ActivityTypeKey: constant.ActTagDeleted,
})
return nil
}

View File

@ -25,7 +25,7 @@ type TagCommonRepo interface {
GetTagBySlugName(ctx context.Context, slugName string) (tagInfo *entity.Tag, exist bool, err error)
GetTagListByName(ctx context.Context, name string, limit int, hasReserved bool) (tagList []*entity.Tag, err error)
GetTagListByNames(ctx context.Context, names []string) (tagList []*entity.Tag, err error)
GetTagByID(ctx context.Context, tagID string) (tag *entity.Tag, exist bool, err error)
GetTagByID(ctx context.Context, tagID string, includeDeleted bool) (tag *entity.Tag, exist bool, err error)
GetTagPage(ctx context.Context, page, pageSize int, tag *entity.Tag, queryCond string) (tagList []*entity.Tag, total int64, err error)
GetRecommendTagList(ctx context.Context) (tagList []*entity.Tag, err error)
GetReservedTagList(ctx context.Context) (tagList []*entity.Tag, err error)
@ -209,7 +209,7 @@ func (ts *TagCommonService) AddTagList(ctx context.Context, tagList []*entity.Ta
// GetTagByID get object tag
func (ts *TagCommonService) GetTagByID(ctx context.Context, tagID string) (tag *entity.Tag, exist bool, err error) {
tag, exist, err = ts.tagCommonRepo.GetTagByID(ctx, tagID)
tag, exist, err = ts.tagCommonRepo.GetTagByID(ctx, tagID, false)
if !exist {
return
}

View File

@ -23,8 +23,9 @@ module.exports = {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
plugins: ['react', '@typescript-eslint'],
plugins: ['react', '@typescript-eslint', 'prettier'],
rules: {
'prettier/prettier': 'error',
'no-unused-vars': 'off',
'no-console': 'off',
'import/prefer-default-export': 'off',
@ -50,6 +51,7 @@ module.exports = {
'react-hooks/exhaustive-deps': 'off',
'react/jsx-props-no-spreading': 'off',
'@typescript-eslint/default-param-last': 'off',
'no-nested-ternary': 'off',
'import/order': [
'error',
{

View File

@ -1,5 +1,5 @@
{
"*.{js,jsx,ts,tsx}": ["eslint --cache --fix"],
"*.{js,jsx,less,md,json}": ["prettier --write"],
"*.{js,jsx,scss,md,json}": ["prettier --write"],
"*.ts?(x)": ["prettier --parser=typescript --write"]
}

View File

@ -1,3 +1,3 @@
module.exports = {
extends: ['@commitlint/routes-conventional'],
extends: ['@commitlint/config-conventional'],
};

View File

@ -9,7 +9,8 @@
"lint": "eslint . --cache --fix --ext .ts,.tsx",
"prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
"prepare": "cd .. && husky install",
"preinstall": "node ./scripts/preinstall.js"
"preinstall": "node ./scripts/preinstall.js",
"pre-commit": "lint-staged"
},
"dependencies": {
"axios": "^0.27.2",
@ -19,6 +20,7 @@
"codemirror": "5.65.0",
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.5",
"diff": "^5.1.0",
"i18next": "^21.9.0",
"katex": "^0.16.2",
"lodash": "^4.17.21",
@ -39,6 +41,7 @@
},
"devDependencies": {
"@commitlint/cli": "^17.0.3",
"@commitlint/config-conventional": "^17.2.0",
"@fullhuman/postcss-purgecss": "^4.1.3",
"@testing-library/dom": "^8.17.1",
"@testing-library/jest-dom": "^4.2.4",

View File

@ -2,6 +2,7 @@ lockfileVersion: 5.4
specifiers:
'@commitlint/cli': ^17.0.3
'@commitlint/config-conventional': ^17.2.0
'@fullhuman/postcss-purgecss': ^4.1.3
'@testing-library/dom': ^8.17.1
'@testing-library/jest-dom': ^4.2.4
@ -25,6 +26,7 @@ specifiers:
copy-to-clipboard: ^3.3.2
customize-cra: ^1.0.0
dayjs: ^1.11.5
diff: ^5.1.0
eslint: ^8.0.1
eslint-config-airbnb: ^19.0.4
eslint-config-airbnb-typescript: ^17.0.0
@ -73,6 +75,7 @@ dependencies:
codemirror: 5.65.0
copy-to-clipboard: 3.3.2
dayjs: 1.11.5
diff: 5.1.0
i18next: 21.9.2
katex: 0.16.2
lodash: 4.17.21
@ -93,6 +96,7 @@ dependencies:
devDependencies:
'@commitlint/cli': 17.1.2
'@commitlint/config-conventional': 17.2.0
'@fullhuman/postcss-purgecss': 4.1.3_postcss@8.4.16
'@testing-library/dom': 8.18.1
'@testing-library/jest-dom': 4.2.4
@ -1421,6 +1425,13 @@ packages:
- '@swc/wasm'
dev: true
/@commitlint/config-conventional/17.2.0:
resolution: {integrity: sha512-g5hQqRa80f++SYS233dbDSg16YdyounMTAhVcmqtInNeY/GF3aA4st9SVtJxpeGrGmueMrU4L+BBb+6Vs5wrcg==}
engines: {node: '>=v14'}
dependencies:
conventional-changelog-conventionalcommits: 5.0.0
dev: true
/@commitlint/config-validator/17.1.0:
resolution: {integrity: sha512-Q1rRRSU09ngrTgeTXHq6ePJs2KrI+axPTgkNYDWSJIuS1Op4w3J30vUfSXjwn5YEJHklK3fSqWNHmBhmTR7Vdg==}
engines: {node: '>=v14'}
@ -3765,6 +3776,15 @@ packages:
q: 1.5.1
dev: true
/conventional-changelog-conventionalcommits/5.0.0:
resolution: {integrity: sha512-lCDbA+ZqVFQGUj7h9QBKoIpLhl8iihkO0nCTyRNzuXtcd7ubODpYB04IFy31JloiJgG0Uovu8ot8oxRzn7Nwtw==}
engines: {node: '>=10'}
dependencies:
compare-func: 2.0.0
lodash: 4.17.21
q: 1.5.1
dev: true
/conventional-commits-parser/3.2.4:
resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==}
engines: {node: '>=10'}
@ -4744,6 +4764,11 @@ packages:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
/diff/5.1.0:
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
engines: {node: '>=0.3.1'}
dev: false
/dir-glob/3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}

View File

@ -557,3 +557,12 @@ export const TIMEZONES = [
},
];
export const DEFAULT_TIMEZONE = 'UTC+0';
export const TIMELINE_NORMAL_ACTIVITY_TYPE = [
'undeleted',
'deleted',
'downvote',
'upvote',
'reopened',
'closed',
];

View File

@ -124,6 +124,7 @@ export interface UserInfoRes extends UserInfoBase {
*/
mail_status: number;
language: string;
is_admin: boolean;
e_mail?: string;
[prop: string]: any;
}
@ -373,3 +374,35 @@ export interface AdminDashboard {
};
};
}
export interface TimelineReq {
show_vote: boolean;
object_id: string;
}
export interface TimelineItem {
activity_id: number;
revision_id: number;
created_at: number;
activity_type: string;
username: string;
user_display_name: string;
comment: string;
object_id: string;
object_type: string;
cancelled: boolean;
cancelled_at: any;
}
export interface TimelineObject {
title: string;
object_type: string;
question_id: string;
answer_id: string;
main_tag_slug_name?: string;
}
export interface TimelineRes {
object_info: TimelineObject;
timeline: TimelineItem[];
}

View File

@ -8,6 +8,7 @@ interface Props {
data: any;
showAvatar?: boolean;
avatarSize?: string;
showReputation?: boolean;
avatarSearchStr?: string;
className?: string;
}
@ -18,6 +19,7 @@ const Index: FC<Props> = ({
avatarSize = '20px',
className = 'fs-14',
avatarSearchStr = 's=48',
showReputation = true,
}) => {
return (
<div className={`text-secondary ${className}`}>
@ -47,9 +49,11 @@ const Index: FC<Props> = ({
</>
)}
<span className="fw-bold" title="Reputation">
{formatCount(data?.rank)}
</span>
{showReputation && (
<span className="fw-bold" title="Reputation">
{formatCount(data?.rank)}
</span>
)}
</div>
);
};

View File

@ -0,0 +1,98 @@
import { FC, memo } from 'react';
import { Tag } from '@/components';
import { diffText } from '@/utils';
interface Props {
objectType: string;
newData: Record<string, any>;
oldData?: Record<string, any>;
className?: string;
}
const Index: FC<Props> = ({ objectType, newData, oldData, className = '' }) => {
if (!newData?.original_text) return null;
let tag = newData.tags;
if (objectType === 'question' && oldData?.tags) {
const addTags = newData.tags.filter(
(c) => !oldData?.tags?.find((p) => p.slug_name === c.slug_name),
);
let deleteTags = oldData?.tags
.filter((c) => !newData?.tags.find((p) => p.slug_name === c.slug_name))
.map((v) => ({ ...v, state: 'delete' }));
deleteTags = deleteTags?.map((v) => {
const index = oldData?.tags?.findIndex(
(c) => c.slug_name === v.slug_name,
);
return {
...v,
pre_index: index,
};
});
tag = newData.tags.map((item) => {
const find = addTags.find((c) => c.slug_name === item.slug_name);
if (find) {
return {
...find,
state: 'add',
};
}
return item;
});
deleteTags.forEach((v) => {
tag.splice(v.pre_index, 0, v);
});
}
return (
<div className={className}>
{objectType !== 'answer' && (
<h5
dangerouslySetInnerHTML={{
__html: diffText(newData.title, oldData?.title),
}}
className="mb-3"
/>
)}
{objectType === 'question' && (
<div className="mb-4">
{tag.map((item) => {
return (
<Tag
key={item.slug_name}
className="me-1"
data={item}
textClassName={`d-inline-block review-text-${item.state}`}
/>
);
})}
</div>
)}
{objectType === 'tag' && (
<div className="mb-4">
{`/tags/${
newData?.main_tag_slug_name
? diffText(
newData.main_tag_slug_name,
oldData?.main_tag_slug_name,
)
: diffText(newData.slug_name, oldData?.slug_name)
}`}
</div>
)}
<div
dangerouslySetInnerHTML={{
__html: diffText(newData.original_text, oldData?.original_text),
}}
className="pre-line"
/>
</div>
);
};
export default memo(Index);

View File

@ -1,13 +1,15 @@
import { FC, memo } from 'react';
import { FC, memo, ReactNode } from 'react';
import { Trans } from 'react-i18next';
const Index: FC = () => {
const Index: FC<{ children?: ReactNode }> = ({ children }) => {
return (
<div className="text-center py-5">
<Trans i18nKey="personal.list_empty">
We couldn't find anything. <br /> Try different or less specific
keywords.
</Trans>
{children || (
<Trans i18nKey="personal.list_empty">
We couldn't find anything. <br /> Try different or less specific
keywords.
</Trans>
)}
</div>
);
};

View File

@ -56,6 +56,12 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
{userInfo?.is_admin ? (
<Dropdown.Item href="/admin">{t('header.nav.admin')}</Dropdown.Item>
) : null}
{/* TODO: use review permission */}
{userInfo?.is_admin ? (
<Dropdown.Item href="/review">
{t('header.nav.review')}
</Dropdown.Item>
) : null}
<Dropdown.Divider />
<Dropdown.Item onClick={logOut}>
{t('header.nav.logout')}

View File

@ -122,9 +122,6 @@ const Header: FC = () => {
<NavLink className="nav-link" to="/tags">
{t('header.nav.tag')}
</NavLink>
<NavLink className="nav-link d-none" to="/users">
{t('header.nav.user')}
</NavLink>
</Nav>
</Col>
<hr className="hr lg-none mt-2" />

View File

@ -8,9 +8,15 @@ interface IProps {
data: Tag;
href?: string;
className?: string;
textClassName?: string;
}
const Index: FC<IProps> = ({ className = '', href, data }) => {
const Index: FC<IProps> = ({
data,
href,
className = '',
textClassName = '',
}) => {
href ||= `/tags/${encodeURIComponent(
data.main_tag_slug_name || data.slug_name,
)}`.toLowerCase();
@ -24,7 +30,7 @@ const Index: FC<IProps> = ({ className = '', href, data }) => {
data.recommend && 'badge-tag-required',
className,
)}>
{data.slug_name}
<span className={textClassName}>{data.slug_name}</span>
</a>
);
};

View File

@ -10,10 +10,19 @@ interface Props {
data: any;
time: number;
preFix: string;
isLogged: boolean;
timelinePath: string;
className?: string;
}
const Index: FC<Props> = ({ data, time, preFix, className = '' }) => {
const Index: FC<Props> = ({
data,
time,
preFix,
isLogged,
timelinePath,
className = '',
}) => {
return (
<div className={classnames('d-flex', className)}>
{data?.status !== 'deleted' ? (
@ -62,7 +71,18 @@ const Index: FC<Props> = ({ data, time, preFix, className = '' }) => {
{formatCount(data?.rank)}
</span>
</div>
{time && <FormatTime time={time} preFix={preFix} />}
{time &&
(isLogged ? (
<Link to={timelinePath}>
<FormatTime
time={time}
preFix={preFix}
className="link-secondary"
/>
</Link>
) : (
<FormatTime time={time} preFix={preFix} />
))}
</div>
</div>
);

View File

@ -27,6 +27,7 @@ import QueryGroup from './QueryGroup';
import BrandUpload from './BrandUpload';
import SchemaForm, { JSONSchema, UISchema, initFormData } from './SchemaForm';
import Labels from './LabelsCard';
import DiffContent from './DiffContent';
export {
Avatar,
@ -60,5 +61,6 @@ export {
SchemaForm,
initFormData,
Labels,
DiffContent,
};
export type { EditorRef, JSONSchema, UISchema };

View File

@ -253,3 +253,33 @@ a {
padding: 1px 0.5rem 2px;
height: 24px;
}
.review-text-delete {
color: #842029;
background-color: #f8d7da;
text-decoration: line-through;
.review-text-add {
text-decoration: none;
}
}
.review-text-add {
color: #0f5132;
background-color: #d1e7dd;
text-decoration: none !important;
}
.rotate-90-deg {
display: inline-block;
transform: rotate(90deg);
transition: transform 0.2s;
}
.rotate-0-deg {
display: inline-block;
transform: rotate(0deg);
transition: transform 0.2s;
}
.pre-line {
white-space: pre-line;
}

View File

@ -25,6 +25,7 @@ interface FormDataItem {
tags: Type.FormValue<Type.Tag[]>;
content: Type.FormValue<string>;
answer: Type.FormValue<string>;
edit_summary: Type.FormValue<string>;
}
const initFormData = {
title: {
@ -47,9 +48,15 @@ const initFormData = {
isInvalid: false,
errorMsg: '',
},
edit_summary: {
value: '',
isInvalid: false,
errorMsg: '',
},
};
const Ask = () => {
const { t } = useTranslation('translation', { keyPrefix: 'ask' });
const [formData, setFormData] = useState<FormDataItem>(initFormData);
const [checked, setCheckState] = useState(false);
const [focusType, setForceType] = useState('');
@ -63,7 +70,6 @@ const Ask = () => {
const { qid } = useParams();
const navigate = useNavigate();
const { t } = useTranslation('translation', { keyPrefix: 'ask' });
const isEdit = qid !== undefined;
const { data: similarQuestions = { list: [] } } = useQueryQuestionByTitle(
@ -114,6 +120,15 @@ const Ask = () => {
answer: { ...formData.answer, value },
});
const handleSummaryChange = (evt: React.ChangeEvent<HTMLInputElement>) =>
setFormData({
...formData,
edit_summary: {
...formData.edit_summary,
value: evt.currentTarget.value,
},
});
const checkValidated = (): boolean => {
let bol = true;
const { title, content, tags, answer } = formData;
@ -205,7 +220,11 @@ const Ask = () => {
tags: formData.tags.value,
};
if (isEdit) {
modifyQuestion({ ...params, id: qid })
modifyQuestion({
...params,
id: qid,
edit_summary: formData.edit_summary.value,
})
.then(() => {
navigate(`/questions/${qid}`);
})
@ -354,6 +373,21 @@ const Ask = () => {
{formData.tags.errorMsg}
</Form.Control.Feedback>
</Form.Group>
{isEdit && (
<Form.Group controlId="edit_summary" className="my-3">
<Form.Label>{t('form.fields.edit_summary.label')}</Form.Label>
<Form.Control
type="text"
defaultValue={formData.edit_summary.value}
isInvalid={formData.edit_summary.isInvalid}
placeholder={t('form.fields.edit_summary.placeholder')}
onChange={handleSummaryChange}
/>
<Form.Control.Feedback type="invalid">
{formData.edit_summary.errorMsg}
</Form.Control.Feedback>
</Form.Group>
)}
{!checked && (
<div className="mt-3">
<Button type="submit" className="me-2">

View File

@ -1,6 +1,7 @@
import { memo, FC, useEffect, useRef } from 'react';
import { Row, Col, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
Actions,
@ -22,12 +23,14 @@ interface Props {
/** is author */
isAuthor: boolean;
questionTitle: string;
isLogged: boolean;
callback: (type: string) => void;
}
const Index: FC<Props> = ({
aid,
data,
isAuthor,
isLogged,
questionTitle = '',
callback,
}) => {
@ -119,7 +122,17 @@ const Index: FC<Props> = ({
data={data?.update_user_info}
time={Number(data.update_time)}
preFix={t('edit')}
isLogged={isLogged}
timelinePath={`/posts/${data.question_id}/${data.id}/timeline`}
/>
) : isLogged ? (
<Link to={`/posts/${data.question_id}/${data.id}/timeline`}>
<FormatTime
time={Number(data.update_time)}
preFix={t('edit')}
className="link-secondary fs-14"
/>
</Link>
) : (
<FormatTime
time={Number(data.update_time)}
@ -133,6 +146,8 @@ const Index: FC<Props> = ({
data={data?.user_info}
time={Number(data.create_time)}
preFix={t('answered')}
isLogged={isLogged}
timelinePath={`/posts/${data.question_id}/${data.id}/timeline`}
/>
</Col>
</Row>

View File

@ -18,10 +18,11 @@ import { following } from '@/services';
interface Props {
data: any;
hasAnswer: boolean;
isLogged: boolean;
initPage: (type: string) => void;
}
const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
const { t } = useTranslation('translation', {
keyPrefix: 'question_detail',
});
@ -133,7 +134,17 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
data={data?.user_info}
time={data.edit_time}
preFix={t('edit')}
isLogged={isLogged}
timelinePath={`/posts/${data.id}/timeline`}
/>
) : isLogged ? (
<Link to={`/posts/${data.id}/timeline`}>
<FormatTime
time={data.edit_time}
preFix={t('edit')}
className="link-secondary fs-14"
/>
</Link>
) : (
<FormatTime
time={data.edit_time}
@ -147,6 +158,8 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
data={data?.user_info}
time={data.create_time}
preFix={t('asked')}
isLogged={isLogged}
timelinePath={`/posts/${data.id}/timeline`}
/>
</Col>
</Row>

View File

@ -39,6 +39,7 @@ const Index = () => {
const { setUsers } = usePageUsers();
const userInfo = loggedUserInfoStore((state) => state.user);
const isAuthor = userInfo?.username === question?.user_info?.username;
const isLogged = Boolean(userInfo?.access_token);
const requestAnswers = async () => {
const res = await getAnswers({
order: order === 'updated' ? order : 'default',
@ -126,6 +127,7 @@ const Index = () => {
data={question}
initPage={initPage}
hasAnswer={answers.count > 0}
isLogged={isLogged}
/>
{answers.count > 0 && (
<>
@ -139,6 +141,7 @@ const Index = () => {
questionTitle={question?.title || ''}
isAuthor={isAuthor}
callback={initPage}
isLogged={isLogged}
/>
);
})}

View File

@ -0,0 +1,74 @@
import { FC } from 'react';
import {
Container,
Row,
Col,
Alert,
Badge,
Stack,
Button,
} from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { BaseUserCard, FormatTime, Empty } from '@/components';
import { loggedUserInfoStore } from '@/stores';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
const { user } = loggedUserInfoStore.getState();
return (
<Container className="pt-2 mt-4 mb-5">
<Row>
<Col lg={{ span: 7, offset: 1 }}>
<h3 className="mb-4">{t('review')}</h3>
<Alert variant="secondary">
<Stack className="align-items-start">
<Badge bg="secondary" className="mb-2">
{t('question_edit')}
</Badge>
<Link to="/review">
How do I test whether variable against multiple
</Link>
<p className="mb-0">
{t('edit_summary')}: Editing part of the code and correcting the
grammar.
</p>
</Stack>
<Stack
direction="horizontal"
gap={1}
className="align-items-baseline mt-2">
<BaseUserCard data={user} avatarSize="24" />
<FormatTime
time={Date.now()}
className="small text-secondary"
preFix={t('proposed')}
/>
</Stack>
</Alert>
</Col>
<Col lg={{ span: 7, offset: 1 }}>Content</Col>
<Col lg={{ span: 7, offset: 1 }}>
<Stack direction="horizontal" gap={2}>
<Button variant="outline-primary">
{t('approve', { keyPrefix: 'btns' })}
</Button>
<Button variant="outline-primary">
{t('reject', { keyPrefix: 'btns' })}
</Button>
<Button variant="outline-primary">
{t('skip', { keyPrefix: 'btns' })}
</Button>
</Stack>
</Col>
<Col lg={{ span: 7, offset: 1 }}>
<Empty>{t('empty')}</Empty>
</Col>
</Row>
</Container>
);
};
export default Index;

View File

@ -12,8 +12,11 @@ import {
saveSynonymsTags,
deleteTag,
} from '@/services';
import { loggedUserInfoStore } from '@/stores';
const TagIntroduction = () => {
const userInfo = loggedUserInfoStore((state) => state.user);
const isLogged = Boolean(userInfo?.access_token);
const [isEdit, setEditState] = useState(false);
const { tagName } = useParams();
const { data: tagInfo } = useTagInfo({ name: tagName });
@ -125,6 +128,13 @@ const TagIntroduction = () => {
</Button>
);
})}
{isLogged && (
<Link
to={`/tags/${tagInfo?.tag_id}/timeline`}
className="link-secondary btn-no-border p-0 fs-14 ms-3">
{t('history')}
</Link>
)}
</div>
</Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">

View File

@ -0,0 +1,142 @@
import { FC, useState } from 'react';
import { Button, Row, Col } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Icon, BaseUserCard, DiffContent, FormatTime } from '@/components';
import { TIMELINE_NORMAL_ACTIVITY_TYPE } from '@/common/constants';
import * as Type from '@/common/interface';
import { getTimelineDetail } from '@/services';
interface Props {
data: Type.TimelineItem;
objectInfo: Type.TimelineObject;
isAdmin: boolean;
revisionList: Type.TimelineItem[];
}
const Index: FC<Props> = ({ data, isAdmin, objectInfo, revisionList }) => {
const { t } = useTranslation('translation', { keyPrefix: 'timeline' });
const [isOpen, setIsOpen] = useState(false);
const [detailData, setDetailData] = useState({
new_revision: {},
old_revision: {},
});
const handleItemClick = async (id) => {
if (!isOpen) {
const revisionItem = revisionList?.find((v) => v.revision_id === id);
let oldId;
if (revisionList?.length > 0 && revisionItem) {
const idIndex = revisionList.indexOf(revisionItem) || 0;
if (idIndex === revisionList.length - 1) {
oldId = 0;
} else {
oldId = revisionList[idIndex + 1].revision_id;
}
}
const res = await getTimelineDetail({
new_revision_id: id,
old_revision_id: oldId,
});
setDetailData(res);
}
setIsOpen(!isOpen);
};
return (
<>
<tr>
<td>
<FormatTime time={data.created_at} />
<br />
{data.cancelled_at > 0 && <FormatTime time={data.cancelled_at} />}
</td>
<td>
{(data.activity_type === 'rollback' ||
data.activity_type === 'edited' ||
data.activity_type === 'asked' ||
data.activity_type === 'created' ||
(objectInfo.object_type === 'answer' &&
data.activity_type === 'answered')) && (
<Button
onClick={() => handleItemClick(data.revision_id)}
variant="link"
className="text-body p-0 btn-no-border">
<Icon
name="caret-right-fill"
className={`me-1 ${isOpen ? 'rotate-90-deg' : 'rotate-0-deg'}`}
/>
{t(data.activity_type)}
</Button>
)}
{data.activity_type === 'accept' && (
<Link to={`/questions/${objectInfo.question_id}`}>
{t(data.activity_type)}
</Link>
)}
{objectInfo.object_type === 'question' &&
data.activity_type === 'answered' && (
<Link
to={`/questions/${objectInfo.question_id}/${data.object_id}`}>
{t(data.activity_type)}
</Link>
)}
{data.activity_type === 'commented' && (
<Link
to={
objectInfo.object_type === 'answer'
? `/questions/${objectInfo.question_id}/${objectInfo.answer_id}?commentId=${data.object_id}`
: `/questions/${objectInfo.question_id}?commentId=${data.object_id}`
}>
{t(data.activity_type)}
</Link>
)}
{TIMELINE_NORMAL_ACTIVITY_TYPE.includes(data.activity_type) && (
<div>{t(data.activity_type)}</div>
)}
{data.cancelled && (
<div className="text-danger">{t('cancelled')}</div>
)}
</td>
<td>
{data.activity_type === 'downvote' && !isAdmin ? (
<div>{t('n_or_a')}</div>
) : (
<BaseUserCard
className="fs-normal"
data={{
username: data.username,
display_name: data.user_display_name,
}}
showAvatar={false}
showReputation={false}
/>
)}
</td>
<td>
<div dangerouslySetInnerHTML={{ __html: data.comment }} />
</td>
</tr>
<tr className={isOpen ? '' : 'd-none'}>
{/* <td /> */}
<td colSpan={5} className="p-0 py-5">
<Row className="justify-content-center">
<Col xxl={8}>
<DiffContent
objectType={objectInfo.object_type}
newData={detailData?.new_revision}
oldData={detailData?.old_revision}
/>
</Col>
</Row>
</td>
</tr>
</>
);
};
export default Index;

View File

@ -0,0 +1,103 @@
import { FC, useState } from 'react';
import { Container, Row, Col, Form, Table } from 'react-bootstrap';
import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { loggedUserInfoStore } from '@/stores';
import { useTimelineData } from '@/services';
import { PageTitle, Empty } from '@/components';
import HistoryItem from './components/Item';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'timeline' });
const { qid = '', aid = '', tid = '' } = useParams();
const { is_admin } = loggedUserInfoStore((state) => state.user);
const [showVotes, setShowVotes] = useState(false);
const { data: timelineData, isLoading } = useTimelineData({
object_id: tid || aid || qid,
show_vote: showVotes,
});
const handleSwitch = (bol: boolean) => {
setShowVotes(bol);
};
let linkUrl = '';
if (timelineData?.object_info.object_type === 'question') {
linkUrl = `/questions/${timelineData?.object_info.question_id}`;
}
if (timelineData?.object_info.object_type === 'answer') {
linkUrl = `/questions/${timelineData?.object_info.question_id}/${timelineData?.object_info.answer_id}`;
}
if (timelineData?.object_info.object_type === 'tag') {
linkUrl = `/tags/${
timelineData?.object_info.main_tag_slug_name ||
timelineData?.object_info.title
}`;
}
const revisionList =
timelineData?.timeline?.filter((item) => item.revision_id > 0) || [];
return (
<Container className="py-3">
<PageTitle
title={
timelineData?.object_info.object_type === 'tag'
? `${t('title_for_tag')} ${timelineData?.object_info.title}`
: `${t('title_for')} ${timelineData?.object_info.title}`
}
/>
<Row className="py-3 justify-content-center">
<Col xxl={10}>
<h5 className="mb-4">
{t('title')}{' '}
<Link to={linkUrl}>{timelineData?.object_info?.title}</Link>
</h5>
{timelineData?.object_info.object_type !== 'tag' && (
<Form.Check
className="mb-4"
type="switch"
id="custom-switch"
label={t('show_votes')}
checked={showVotes}
onChange={(e) => handleSwitch(e.target.checked)}
/>
)}
<Table hover>
<thead>
<tr>
<th style={{ width: '20%' }}>{t('datetime')}</th>
<th style={{ width: '15%' }}>{t('type')}</th>
<th style={{ width: '19%' }}>{t('by')}</th>
<th>{t('comment')}</th>
</tr>
</thead>
<tbody>
{timelineData?.timeline?.map((item) => {
return (
<HistoryItem
data={item}
objectInfo={timelineData?.object_info}
key={item.activity_id}
isAdmin={is_admin}
revisionList={revisionList}
/>
);
})}
</tbody>
</Table>
{!isLoading && Number(timelineData?.timeline?.length) <= 0 && (
<Empty>{t('no_data')}</Empty>
)}
</Col>
</Row>
</Container>
);
};
export default Index;

View File

@ -83,7 +83,7 @@ const routes: RouteNode[] = [
return guard.activated();
},
},
// users
// for users
{
path: 'users/:username',
page: 'pages/Users/Personal',
@ -191,6 +191,27 @@ const routes: RouteNode[] = [
return guard.forbidden();
},
},
{
path: '/posts/:qid/timeline',
page: 'pages/Timeline',
guard: async () => {
return guard.logged();
},
},
{
path: '/posts/:qid/:aid/timeline',
page: 'pages/Timeline',
guard: async () => {
return guard.logged();
},
},
{
path: '/tags/:tid/timeline',
page: 'pages/Timeline',
guard: async () => {
return guard.logged();
},
},
// for admin
{
path: 'admin',
@ -254,6 +275,11 @@ const routes: RouteNode[] = [
},
],
},
// for review
{
path: 'review',
page: 'pages/Review',
},
{
path: '*',
page: 'pages/404',

View File

@ -6,3 +6,4 @@ export * from './search';
export * from './tag';
export * from './settings';
export * from './legal';
export * from './timeline';

View File

@ -0,0 +1,30 @@
import useSWR from 'swr';
import qs from 'qs';
import request from '@/utils/request';
import type * as Type from '@/common/interface';
export const useTimelineData = (params: Type.TimelineReq) => {
const apiUrl = '/answer/api/v1/activity/timeline';
const { data, error, mutate } = useSWR<Type.TimelineRes, Error>(
`${apiUrl}?${qs.stringify(params, { skipNulls: true })}`,
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
mutate,
};
};
export const getTimelineDetail = (params: {
new_revision_id: string;
old_revision_id: string;
}) => {
return request.get(
`/answer/api/v1/activity/timeline/detail?${qs.stringify(params, {
skipNulls: true,
})}`,
);
};

View File

@ -73,7 +73,7 @@ export const useQueryAnswerInfo = (id: string) => {
};
export const modifyQuestion = (
params: Type.QuestionParams & { id: string },
params: Type.QuestionParams & { id: string; edit_summary: string },
) => {
return request.put(`/answer/api/v1/question`, params);
};

View File

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -26,6 +26,7 @@ const initUser: UserInfoRes = {
status: '',
mail_status: 1,
language: 'Default',
is_admin: false,
};
const loggedUserInfoStore = create<UserInfoStore>((set) => ({

View File

@ -1,5 +1,7 @@
import i18next from 'i18next';
const Diff = require('diff');
function thousandthDivision(num) {
const reg = /\d{1,3}(?=(\d{3})+$)/g;
return `${num}`.replace(reg, '$&,');
@ -163,6 +165,34 @@ function handleFormError(
return data;
}
function diffText(newText: string, oldText: string): string {
if (!newText) {
return '';
}
if (!oldText) {
return newText
?.replace(/\n/gi, '<br>')
?.replace(/<iframe/gi, '&lt;iframe')
?.replace(/<input/gi, '&lt;input');
}
const diff = Diff.diffChars(oldText, newText);
const result = diff.map((part) => {
if (part.added) {
return `<span class="review-text-add">${part.value}</span>`;
}
if (part.removed) {
return `<span class="review-text-delete">${part.value}</span>`;
}
return part.value;
});
return result
.join('')
?.replace(/<iframe/gi, '&lt;iframe')
?.replace(/<input/gi, '&lt;input');
}
export {
thousandthDivision,
formatCount,
@ -175,4 +205,5 @@ export {
colorRgb,
labelStyle,
handleFormError,
diffText,
};