diff --git a/docs/docs.go b/docs/docs.go index d394921a..980659fc 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3654,6 +3654,45 @@ const docTemplate = `{ } } }, + "/answer/api/v1/question/operation": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Operation question \\n operation [pin unpin hide show]", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Question" + ], + "summary": "Operation question", + "parameters": [ + { + "description": "question", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.OperationQuestionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/api/v1/question/page": { "get": { "description": "get questions by page", @@ -7442,6 +7481,21 @@ const docTemplate = `{ } } }, + "schema.OperationQuestionReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "operation": { + "description": "operation [pin unpin hide show]", + "type": "string" + } + } + }, "schema.PermissionMemberAction": { "type": "object", "properties": { @@ -7611,6 +7665,14 @@ const docTemplate = `{ "operator": { "$ref": "#/definitions/schema.QuestionPageRespOperator" }, + "pin": { + "description": "1: unpin, 2: pin", + "type": "integer" + }, + "show": { + "description": "0: show, 1: hide", + "type": "integer" + }, "status": { "type": "integer" }, diff --git a/docs/swagger.json b/docs/swagger.json index 8a98a852..b578b650 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3642,6 +3642,45 @@ } } }, + "/answer/api/v1/question/operation": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Operation question \\n operation [pin unpin hide show]", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Question" + ], + "summary": "Operation question", + "parameters": [ + { + "description": "question", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.OperationQuestionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/api/v1/question/page": { "get": { "description": "get questions by page", @@ -7430,6 +7469,21 @@ } } }, + "schema.OperationQuestionReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "operation": { + "description": "operation [pin unpin hide show]", + "type": "string" + } + } + }, "schema.PermissionMemberAction": { "type": "object", "properties": { @@ -7599,6 +7653,14 @@ "operator": { "$ref": "#/definitions/schema.QuestionPageRespOperator" }, + "pin": { + "description": "1: unpin, 2: pin", + "type": "integer" + }, + "show": { + "description": "0: show, 1: hide", + "type": "integer" + }, "status": { "type": "integer" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b25306f1..3cd72824 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1128,6 +1128,16 @@ definitions: description: inbox achievement type: string type: object + schema.OperationQuestionReq: + properties: + id: + type: string + operation: + description: operation [pin unpin hide show] + type: string + required: + - id + type: object schema.PermissionMemberAction: properties: action: @@ -1249,6 +1259,12 @@ definitions: type: string operator: $ref: '#/definitions/schema.QuestionPageRespOperator' + pin: + description: '1: unpin, 2: pin' + type: integer + show: + description: '0: show, 1: hide' + type: integer status: type: integer tags: @@ -4405,6 +4421,30 @@ paths: summary: get question details tags: - Question + /answer/api/v1/question/operation: + put: + consumes: + - application/json + description: Operation question \n operation [pin unpin hide show] + parameters: + - description: question + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.OperationQuestionReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: Operation question + tags: + - Question /answer/api/v1/question/page: get: consumes: diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5c3ceded..0d0bc7ce 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -25,6 +25,14 @@ backend: other: Reopen forbidden_error: other: Forbidden. + pin: + other: Pin + hide: + other: Unlist + unpin: + other: Unpin + show: + other: List role: name: user: @@ -868,6 +876,7 @@ ui: btn: Add question answers: answers question_detail: + action: Action Asked: Asked asked: asked update: Modified @@ -906,13 +915,14 @@ ui: li1_2: Back up any statements you make with references or personal experience. header_2: But avoid ... li2_1: Asking for help, seeking clarification, or responding to other answers. - reopen: confirm_btn: Reopen title: Reopen this post content: Are you sure you want to reopen? - success: This post has been reopened - + pin: + title: Pin this post + content: Are you sure you wish to pinned globally? This post will appear at the top of all post lists. + confirm_btn: Pin delete: title: Delete this post question: >- @@ -926,7 +936,6 @@ ui: of accepted answers can result in your account being blocked from answering. Are you sure you wish to delete? other: Are you sure you wish to delete? - tip_question_deleted: This post has been deleted tip_answer_deleted: This answer has been deleted btns: confirm: Confirm @@ -942,6 +951,7 @@ ui: reject: Reject skip: Skip discard_draft: Discard draft + pinned: Pinned search: title: Search Results keywords: Keywords @@ -1574,6 +1584,10 @@ ui: closed: closed reopened: reopened created: created + pin: pinned + unpin: unpinned + show: listed + hide: unlisted title: "History for" tag_title: "Timeline for" show_votes: "Show votes" @@ -1599,5 +1613,9 @@ ui: draft: discard_confirm: Are you sure you want to discard your draft? messages: - post_deleted: This post has been deleted. - + post_deleted: This post has been deleted. + post_pin: This post has been pinned. + post_unpin: This post has been unpinned. + post_hide_list: This post has been hidden from list. + post_show_list: This post has been shown to list. + post_reopen: This post has been reopened. diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index b468fd38..b6d925a3 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -22,6 +22,14 @@ backend: other: 关闭 reopen: other: 重新打开 + pin: + other: 置顶 + hide: + other: 隐藏 + unpin: + other: 取消置顶 + show: + other: 显示 role: name: user: diff --git a/internal/base/constant/acticity.go b/internal/base/constant/acticity.go index 8300147b..334d8e6d 100644 --- a/internal/base/constant/acticity.go +++ b/internal/base/constant/acticity.go @@ -14,6 +14,10 @@ const ( ActFollow = "follow" ActAccepted = "accepted" ActAccept = "accept" + ActPin = "pin" + ActUnPin = "unpin" + ActShow = "show" + ActHide = "hide" ) const ( @@ -29,6 +33,10 @@ const ( ActQuestionRollback ActivityTypeKey = "question.rollback" ActQuestionDeleted ActivityTypeKey = "question.deleted" ActQuestionUndeleted ActivityTypeKey = "question.undeleted" + ActQuestionPin ActivityTypeKey = "question.pin" + ActQuestionUnPin ActivityTypeKey = "question.unpin" + ActQuestionHide ActivityTypeKey = "question.hide" + ActQuestionShow ActivityTypeKey = "question.show" ) const ( diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 128b1e9e..3ecf6bc3 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -70,6 +70,47 @@ func (qc *QuestionController) RemoveQuestion(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) } +// OperationQuestion Operation question +// @Summary Operation question +// @Description Operation question \n operation [pin unpin hide show] +// @Tags Question +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.OperationQuestionReq true "question" +// @Success 200 {object} handler.RespBody +// @Router /answer/api/v1/question/operation [put] +func (qc *QuestionController) OperationQuestion(ctx *gin.Context) { + req := &schema.OperationQuestionReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.ID = uid.DeShortID(req.ID) + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + canList, err := qc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{ + permission.QuestionPin, + permission.QuestionUnPin, + permission.QuestionHide, + permission.QuestionShow, + }) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + req.CanPin = canList[0] + req.CanList = canList[1] + if (req.Operation == schema.QuestionOperationPin || req.Operation == schema.QuestionOperationUnPin) && !req.CanPin { + handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + return + } + if (req.Operation == schema.QuestionOperationHide || req.Operation == schema.QuestionOperationShow) && !req.CanList { + handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + return + } + err = qc.questionService.OperationQuestion(ctx, req) + handler.HandleResponse(ctx, err, nil) +} + // CloseQuestion Close question // @Summary Close question // @Description Close question @@ -152,6 +193,10 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) { permission.QuestionDelete, permission.QuestionClose, permission.QuestionReopen, + permission.QuestionPin, + permission.QuestionUnPin, + permission.QuestionHide, + permission.QuestionShow, }) if err != nil { handler.HandleResponse(ctx, err, nil) @@ -163,6 +208,10 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) { req.CanDelete = canList[1] req.CanClose = canList[2] req.CanReopen = canList[3] + req.CanPin = canList[4] + req.CanUnPin = canList[5] + req.CanHide = canList[6] + req.CanShow = canList[7] info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req) if err != nil { diff --git a/internal/entity/question_entity.go b/internal/entity/question_entity.go index 4b4f4923..41c9236b 100644 --- a/internal/entity/question_entity.go +++ b/internal/entity/question_entity.go @@ -8,6 +8,10 @@ const ( QuestionStatusAvailable = 1 QuestionStatusClosed = 2 QuestionStatusDeleted = 10 + QuestionUnPin = 1 + QuestionPin = 2 + QuestionShow = 1 + QuestionHide = 2 ) var AdminQuestionSearchStatus = map[string]int{ @@ -32,6 +36,8 @@ type Question struct { Title string `xorm:"not null default '' VARCHAR(150) title"` OriginalText string `xorm:"not null MEDIUMTEXT original_text"` ParsedText string `xorm:"not null MEDIUMTEXT parsed_text"` + Pin int `xorm:"not null default 1 INT(11) pin"` + Show int `xorm:"not null default 1 INT(11) show"` Status int `xorm:"not null default 1 INT(11) status"` ViewCount int `xorm:"not null default 0 INT(11) view_count"` UniqueViewCount int `xorm:"not null default 0 INT(11) unique_view_count"` diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index af6b01fc..59b9138c 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -56,6 +56,7 @@ var migrations = []Migration{ NewMigration("add user role", addRoleFeatures, false), NewMigration("add theme and private mode", addThemeAndPrivateMode, true), NewMigration("add new answer notification", addNewAnswerNotification, true), + NewMigration("add user pin hide features", addRolePinAndHideFeatures, true), NewMigration("add plugin", addPlugin, false), NewMigration("add login limitations", addLoginLimitations, true), } diff --git a/internal/migrations/v8.go b/internal/migrations/v8.go new file mode 100644 index 00000000..090651fa --- /dev/null +++ b/internal/migrations/v8.go @@ -0,0 +1,118 @@ +package migrations + +import ( + "fmt" + "time" + + "github.com/answerdev/answer/internal/entity" + "github.com/answerdev/answer/internal/service/permission" + "github.com/segmentfault/pacman/log" + "xorm.io/xorm" +) + +func addRolePinAndHideFeatures(x *xorm.Engine) error { + + powers := []*entity.Power{ + {ID: 34, Name: "question pin", PowerType: permission.QuestionPin, Description: "top the question"}, + {ID: 35, Name: "question hide", PowerType: permission.QuestionHide, Description: "hide the question"}, + {ID: 36, Name: "question unpin", PowerType: permission.QuestionUnPin, Description: "untop the question"}, + {ID: 37, Name: "question show", PowerType: permission.QuestionShow, Description: "show the question"}, + } + // insert default powers + for _, power := range powers { + exist, err := x.Get(&entity.Power{ID: power.ID}) + if err != nil { + return err + } + if exist { + _, err = x.ID(power.ID).Update(power) + } else { + _, err = x.Insert(power) + } + if err != nil { + return err + } + } + + rolePowerRels := []*entity.RolePowerRel{ + + {RoleID: 2, PowerType: permission.QuestionPin}, + {RoleID: 2, PowerType: permission.QuestionHide}, + {RoleID: 2, PowerType: permission.QuestionUnPin}, + {RoleID: 2, PowerType: permission.QuestionShow}, + + {RoleID: 3, PowerType: permission.QuestionPin}, + {RoleID: 3, PowerType: permission.QuestionHide}, + {RoleID: 3, PowerType: permission.QuestionUnPin}, + {RoleID: 3, PowerType: permission.QuestionShow}, + } + + // insert default powers + for _, rel := range rolePowerRels { + exist, err := x.Get(&entity.RolePowerRel{RoleID: rel.RoleID, PowerType: rel.PowerType}) + if err != nil { + return err + } + if exist { + continue + } + _, err = x.Insert(rel) + if err != nil { + return err + } + } + + defaultConfigTable := []*entity.Config{ + {ID: 119, Key: "question.pin", Value: `0`}, + {ID: 120, Key: "question.unpin", Value: `0`}, + {ID: 121, Key: "question.show", Value: `0`}, + {ID: 122, Key: "question.hide", Value: `0`}, + } + for _, c := range defaultConfigTable { + exist, err := x.Get(&entity.Config{ID: c.ID, Key: c.Key}) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if exist { + if _, err = x.Update(c, &entity.Config{ID: c.ID, Key: c.Key}); err != nil { + log.Errorf("update %+v config failed: %s", c, err) + return fmt.Errorf("update config failed: %w", err) + } + continue + } + if _, err = x.Insert(&entity.Config{ID: c.ID, Key: c.Key, Value: c.Value}); err != nil { + log.Errorf("insert %+v config failed: %s", c, err) + return fmt.Errorf("add config failed: %w", err) + } + } + + type Question struct { + ID string `xorm:"not null pk BIGINT(20) id"` + CreatedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated_at TIMESTAMP"` + UserID string `xorm:"not null default 0 BIGINT(20) INDEX user_id"` + LastEditUserID string `xorm:"not null default 0 BIGINT(20) last_edit_user_id"` + Title string `xorm:"not null default '' VARCHAR(150) title"` + OriginalText string `xorm:"not null MEDIUMTEXT original_text"` + ParsedText string `xorm:"not null MEDIUMTEXT parsed_text"` + Status int `xorm:"not null default 1 INT(11) status"` + Pin int `xorm:"not null default 1 INT(11) pin"` + Show int `xorm:"not null default 1 INT(11) show"` + ViewCount int `xorm:"not null default 0 INT(11) view_count"` + UniqueViewCount int `xorm:"not null default 0 INT(11) unique_view_count"` + VoteCount int `xorm:"not null default 0 INT(11) vote_count"` + AnswerCount int `xorm:"not null default 0 INT(11) answer_count"` + CollectionCount int `xorm:"not null default 0 INT(11) collection_count"` + FollowCount int `xorm:"not null default 0 INT(11) follow_count"` + AcceptedAnswerID string `xorm:"not null default 0 BIGINT(20) accepted_answer_id"` + LastAnswerID string `xorm:"not null default 0 BIGINT(20) last_answer_id"` + PostUpdateTime time.Time `xorm:"post_update_time TIMESTAMP"` + RevisionID string `xorm:"not null default 0 BIGINT(20) revision_id"` + } + err := x.Sync(new(Question)) + if err != nil { + return err + } + + return nil +} diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 5b1ab705..da9d048f 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -125,6 +125,15 @@ func (qr *questionRepo) UpdateQuestionStatusWithOutUpdateTime(ctx context.Contex return nil } +func (qr *questionRepo) UpdateQuestionOperation(ctx context.Context, question *entity.Question) (err error) { + question.ID = uid.DeShortID(question.ID) + _, err = qr.data.DB.Where("id =?", question.ID).Cols("pin", "show").Update(question) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + func (qr *questionRepo) UpdateAccepted(ctx context.Context, question *entity.Question) (err error) { question.ID = uid.DeShortID(question.ID) _, err = qr.data.DB.Where("id =?", question.ID).Cols("accepted_answer_id").Update(question) @@ -224,6 +233,7 @@ func (qr *questionRepo) GetQuestionIDsPage(ctx context.Context, page, pageSize i offset := page * pageSize session := qr.data.DB.Table("question") session = session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed}) + session.And("question.show = ?", entity.QuestionShow) session = session.Limit(pageSize, offset) session = session.OrderBy("question.created_at asc") err = session.Select("id,title,created_at,post_update_time").Find(&rows) @@ -258,19 +268,22 @@ func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, } if len(userID) > 0 { session.And("question.user_id = ?", userID) + } else { + session.And("question.show = ?", entity.QuestionShow) } + switch orderCond { case "newest": - session.OrderBy("question.created_at DESC") + session.OrderBy("question.pin desc,question.created_at DESC") case "active": - session.OrderBy("question.post_update_time DESC, question.updated_at DESC") + session.OrderBy("question.pin desc,question.post_update_time DESC, question.updated_at DESC") case "frequent": - session.OrderBy("question.view_count DESC") + session.OrderBy("question.pin desc,question.view_count DESC") case "score": - session.OrderBy("question.vote_count DESC, question.view_count DESC") + session.OrderBy("question.pin desc,question.vote_count DESC, question.view_count DESC") case "unanswered": session.Where("question.last_answer_id = 0") - session.OrderBy("question.created_at DESC") + session.OrderBy("question.pin desc,question.created_at DESC") } total, err = pager.Help(page, pageSize, &questionList, &entity.Question{}, session) diff --git a/internal/repo/search_common/search_repo.go b/internal/repo/search_common/search_repo.go index 987fea5c..dc07e5ae 100644 --- a/internal/repo/search_common/search_repo.go +++ b/internal/repo/search_common/search_repo.go @@ -94,12 +94,14 @@ func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagIDs ub = builder.MySQL().Select(afs...).From("`answer`"). LeftJoin("`question`", "`question`.id = `answer`.question_id") - b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}) + b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}). + And(builder.Eq{"`question`.`show`": entity.QuestionShow}) ub.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}). - And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}) + And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}). + And(builder.Eq{"`question`.`show`": entity.QuestionShow}) - argsQ = append(argsQ, entity.QuestionStatusDeleted) - argsA = append(argsA, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted) + argsQ = append(argsQ, entity.QuestionStatusDeleted, entity.QuestionShow) + argsA = append(argsA, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow) for i, word := range words { if i == 0 { @@ -228,8 +230,8 @@ func (sr *searchRepo) SearchQuestions(ctx context.Context, words []string, tagID b := builder.MySQL().Select(qfs...).From("question") - b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}) - args = append(args, entity.QuestionStatusDeleted) + b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow}) + args = append(args, entity.QuestionStatusDeleted, entity.QuestionShow) for i, word := range words { if i == 0 { @@ -343,8 +345,8 @@ func (sr *searchRepo) SearchAnswers(ctx context.Context, words []string, tagIDs LeftJoin("`question`", "`question`.id = `answer`.question_id") b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}). - And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}) - args = append(args, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted) + And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow}) + args = append(args, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow) for i, word := range words { if i == 0 { diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 1592e70e..7f9a801f 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -195,6 +195,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { r.PUT("/question", a.questionController.UpdateQuestion) r.DELETE("/question", a.questionController.RemoveQuestion) r.PUT("/question/status", a.questionController.CloseQuestion) + r.PUT("/question/operation", a.questionController.OperationQuestion) r.PUT("/question/reopen", a.questionController.ReopenQuestion) r.GET("/question/similar", a.questionController.SearchByTitleLike) diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index 19a2c3fd..b86dcff9 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -8,9 +8,13 @@ import ( ) const ( - SitemapMaxSize = 50000 - SitemapCachekey = "answer@sitemap" - SitemapPageCachekey = "answer@sitemap@page%d" + SitemapMaxSize = 50000 + SitemapCachekey = "answer@sitemap" + SitemapPageCachekey = "answer@sitemap@page%d" + QuestionOperationPin = "pin" + QuestionOperationUnPin = "unpin" + QuestionOperationHide = "hide" + QuestionOperationShow = "show" ) // RemoveQuestionReq delete question request @@ -28,6 +32,14 @@ type CloseQuestionReq struct { UserID string `json:"-"` // user_id } +type OperationQuestionReq struct { + ID string `validate:"required" json:"id"` + Operation string `json:"operation"` // operation [pin unpin hide show] + UserID string `json:"-"` // user_id + CanPin bool `json:"-"` + CanList bool `json:"-"` +} + type CloseQuestionMeta struct { CloseType int `json:"close_type"` CloseMsg string `json:"close_msg"` @@ -101,6 +113,12 @@ type QuestionPermission struct { CanClose bool `json:"-"` // whether user can reopen it CanReopen bool `json:"-"` + // whether user can pin it + CanPin bool `json:"-"` + CanUnPin bool `json:"-"` + // whether user can hide it + CanHide bool `json:"-"` + CanShow bool `json:"-"` // whether user can use reserved it CanUseReservedTag bool `json:"-"` } @@ -168,6 +186,8 @@ type QuestionInfo struct { UpdateTime int64 `json:"-"` // update_time PostUpdateTime int64 `json:"update_time"` QuestionUpdateTime int64 `json:"edit_time"` + Pin int `json:"pin"` // 1: unpin, 2: pin + Show int `json:"show"` // 0: show, 1: hide Status int `json:"status"` Operation *Operation `json:"operation,omitempty"` UserID string `json:"-" ` @@ -295,6 +315,8 @@ type QuestionPageResp struct { Title string `json:"title"` UrlTitle string `json:"url_title"` Description string `json:"description"` + Pin int `json:"pin"` // 1: unpin, 2: pin + Show int `json:"show"` // 0: show, 1: hide Status int `json:"status"` Tags []*TagResp `json:"tags"` diff --git a/internal/service/permission/permission_name.go b/internal/service/permission/permission_name.go index 4a62ec86..1d49ce3f 100644 --- a/internal/service/permission/permission_name.go +++ b/internal/service/permission/permission_name.go @@ -10,6 +10,10 @@ const ( QuestionReopen = "question.reopen" QuestionVoteUp = "question.vote_up" QuestionVoteDown = "question.vote_down" + QuestionPin = "question.pin" //Top the question + QuestionUnPin = "question.unpin" //untop the question + QuestionHide = "question.hide" //hide the question + QuestionShow = "question.show" //show the question AnswerAdd = "answer.add" AnswerEdit = "answer.edit" AnswerEditWithoutReview = "answer.edit_without_review" @@ -43,4 +47,8 @@ const ( deleteActionName = "action.delete" closeActionName = "action.close" reopenActionName = "action.reopen" + pinActionName = "action.pin" + unpinActionName = "action.unpin" + hideActionName = "action.hide" + showActionName = "action.show" ) diff --git a/internal/service/permission/question_permission.go b/internal/service/permission/question_permission.go index 1321af45..6f4d126f 100644 --- a/internal/service/permission/question_permission.go +++ b/internal/service/permission/question_permission.go @@ -10,7 +10,7 @@ import ( // GetQuestionPermission get question permission func GetQuestionPermission(ctx context.Context, userID string, creatorUserID string, - canEdit, canDelete, canClose, canReopen bool) ( + canEdit, canDelete, canClose, canReopen, canPin, canHide, CanUnPin, canShow bool) ( actions []*schema.PermissionMemberAction) { lang := handler.GetLangByCtx(ctx) actions = make([]*schema.PermissionMemberAction, 0) @@ -42,6 +42,36 @@ func GetQuestionPermission(ctx context.Context, userID string, creatorUserID str Type: "confirm", }) } + if canPin { + actions = append(actions, &schema.PermissionMemberAction{ + Action: "pin", + Name: translator.Tr(lang, pinActionName), + Type: "confirm", + }) + } + if canHide { + actions = append(actions, &schema.PermissionMemberAction{ + Action: "hide", + Name: translator.Tr(lang, hideActionName), + Type: "confirm", + }) + } + + if CanUnPin { + actions = append(actions, &schema.PermissionMemberAction{ + Action: "unpin", + Name: translator.Tr(lang, unpinActionName), + Type: "confirm", + }) + } + + if canShow { + actions = append(actions, &schema.PermissionMemberAction{ + Action: "show", + Name: translator.Tr(lang, showActionName), + Type: "confirm", + }) + } if canDelete || userID == creatorUserID { actions = append(actions, &schema.PermissionMemberAction{ Action: "delete", diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index a0d039fc..842670f4 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -36,6 +36,7 @@ type QuestionRepo interface { questionList []*entity.Question, total int64, err error) UpdateQuestionStatus(ctx context.Context, question *entity.Question) (err error) UpdateQuestionStatusWithOutUpdateTime(ctx context.Context, question *entity.Question) (err error) + UpdateQuestionOperation(ctx context.Context, question *entity.Question) (err error) SearchByTitleLike(ctx context.Context, title string) (questionList []*entity.Question, err error) UpdatePvCount(ctx context.Context, questionID string) (err error) UpdateAnswerCount(ctx context.Context, questionID string, num int) (err error) @@ -271,6 +272,8 @@ func (qs *QuestionCommon) FormatQuestionsPage( FollowCount: questionInfo.FollowCount, AcceptedAnswerID: questionInfo.AcceptedAnswerID, LastAnswerID: questionInfo.LastAnswerID, + Pin: questionInfo.Pin, + Show: questionInfo.Show, } questionIDs = append(questionIDs, questionInfo.ID) @@ -526,6 +529,8 @@ func (qs *QuestionCommon) ShowFormat(ctx context.Context, data *entity.Question) info.QuestionUpdateTime = 0 } info.Status = data.Status + info.Pin = data.Pin + info.Show = data.Show info.UserID = data.UserID info.LastEditUserID = data.LastEditUserID if data.LastAnswerID != "0" { diff --git a/internal/service/question_service.go b/internal/service/question_service.go index 5c4b2f3f..c66497be 100644 --- a/internal/service/question_service.go +++ b/internal/service/question_service.go @@ -270,6 +270,8 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question question.Status = entity.QuestionStatusAvailable question.RevisionID = "0" question.CreatedAt = now + question.Pin = entity.QuestionUnPin + question.Show = entity.QuestionShow //question.UpdatedAt = nil err = qs.questionRepo.AddQuestion(ctx, question) if err != nil { @@ -319,6 +321,58 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question return } +// OperationQuestion +func (qs *QuestionService) OperationQuestion(ctx context.Context, req *schema.OperationQuestionReq) (err error) { + questionInfo, has, err := qs.questionRepo.GetQuestion(ctx, req.ID) + if err != nil { + return err + } + if !has { + return nil + } + // Hidden question cannot be placed at the top + if questionInfo.Show == entity.QuestionHide && req.Operation == schema.QuestionOperationPin { + return nil + } + // Question cannot be hidden when they are at the top + if questionInfo.Pin == entity.QuestionPin && req.Operation == schema.QuestionOperationHide { + return nil + } + + switch req.Operation { + case schema.QuestionOperationHide: + questionInfo.Show = entity.QuestionHide + case schema.QuestionOperationShow: + questionInfo.Show = entity.QuestionShow + case schema.QuestionOperationPin: + questionInfo.Pin = entity.QuestionPin + case schema.QuestionOperationUnPin: + questionInfo.Pin = entity.QuestionUnPin + } + + err = qs.questionRepo.UpdateQuestionOperation(ctx, questionInfo) + if err != nil { + return err + } + + actMap := make(map[string]constant.ActivityTypeKey) + actMap[schema.QuestionOperationPin] = constant.ActQuestionPin + actMap[schema.QuestionOperationUnPin] = constant.ActQuestionUnPin + actMap[schema.QuestionOperationHide] = constant.ActQuestionHide + actMap[schema.QuestionOperationShow] = constant.ActQuestionShow + _, ok := actMap[req.Operation] + if ok { + activity_queue.AddActivity(&schema.ActivityMsg{ + UserID: req.UserID, + ObjectID: questionInfo.ID, + OriginalObjectID: questionInfo.ID, + ActivityTypeKey: actMap[req.Operation], + }) + } + + return nil +} + // RemoveQuestion delete question func (qs *QuestionService) RemoveQuestion(ctx context.Context, req *schema.RemoveQuestionReq) (err error) { questionInfo, has, err := qs.questionRepo.GetQuestion(ctx, req.ID) @@ -632,6 +686,21 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s if question.Status == entity.QuestionStatusClosed { per.CanClose = false } + if question.Pin == entity.QuestionPin { + per.CanPin = false + per.CanHide = false + } + if question.Pin == entity.QuestionUnPin { + per.CanUnPin = false + } + if question.Show == entity.QuestionShow { + per.CanShow = false + } + if question.Show == entity.QuestionHide { + per.CanHide = false + per.CanPin = false + } + if question.Status == entity.QuestionStatusDeleted { operation := &schema.Operation{} operation.Msg = translator.Tr(handler.GetLangByCtx(ctx), reason.QuestionAlreadyDeleted) @@ -641,7 +710,7 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s question.Description = htmltext.FetchExcerpt(question.HTML, "...", 240) question.MemberActions = permission.GetQuestionPermission(ctx, userID, question.UserID, - per.CanEdit, per.CanDelete, per.CanClose, per.CanReopen) + per.CanEdit, per.CanDelete, per.CanClose, per.CanReopen, per.CanPin, per.CanHide, per.CanUnPin, per.CanShow) return question, nil } @@ -735,14 +804,15 @@ func (qs *QuestionService) SearchUserAnswerList(ctx context.Context, userName, o if ok { item.QuestionInfo = questionMaps[item.QuestionID] } - } - for _, item := range answerlist { info := &schema.UserAnswerInfo{} _ = copier.Copy(info, item) info.AnswerID = item.ID info.QuestionID = item.QuestionID - userAnswerlist = append(userAnswerlist, info) + if item.QuestionInfo.Status != entity.QuestionStatusDeleted { + userAnswerlist = append(userAnswerlist, info) + } } + return userAnswerlist, count, nil } diff --git a/ui/config-overrides.js b/ui/config-overrides.js index 60b66a51..a82d9822 100644 --- a/ui/config-overrides.js +++ b/ui/config-overrides.js @@ -1,6 +1,6 @@ const { addWebpackModuleRule, - addWebpackAlias + addWebpackAlias, } = require("customize-cra"); const path = require("path"); diff --git a/ui/package.json b/ui/package.json index 22c97cbe..95a88c55 100644 --- a/ui/package.json +++ b/ui/package.json @@ -82,7 +82,7 @@ "react-app-rewired": "^2.2.1", "react-scripts": "5.0.1", "sass": "^1.54.4", - "typescript": "^4.9.5", + "typescript": "^4.8.3", "yaml-loader": "^0.8.0" }, "packageManager": "pnpm@7.9.5", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 13cf786b..b33ae23c 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -66,7 +66,7 @@ specifiers: sass: ^1.54.4 semver: ^7.3.8 swr: ^1.3.0 - typescript: ^4.9.5 + typescript: ^4.8.3 urlcat: ^3.0.0 yaml-loader: ^0.8.0 zustand: ^4.1.1 diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index b753020d..727b91b9 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -609,6 +609,10 @@ export const TIMELINE_NORMAL_ACTIVITY_TYPE = [ 'upvote', 'reopened', 'closed', + 'pin', + 'unpin', + 'show', + 'hide', ]; export const SYSTEM_AVATAR_OPTIONS = [ diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index f10575a2..9907e0a9 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -585,3 +585,8 @@ export interface PluginConfig { slug_name: string; config_fields: PluginItem[]; } + +export interface QuestionOperationReq { + id: string; + operation: 'pin' | 'unpin' | 'hide' | 'show'; +} diff --git a/ui/src/components/Customize/index.tsx b/ui/src/components/Customize/index.tsx index 8f99cc05..33875414 100644 --- a/ui/src/components/Customize/index.tsx +++ b/ui/src/components/Customize/index.tsx @@ -34,7 +34,9 @@ const ActivateScriptNodes = (el, part) => { } scriptList?.forEach((so) => { const script = document.createElement('script'); - script.text = so.text; + script.text = `(() => { + ${so.text} + })();`; for (let i = 0; i < so.attributes.length; i += 1) { const attr = so.attributes[i]; script.setAttribute(attr.name, attr.value); diff --git a/ui/src/components/Icon/index.tsx b/ui/src/components/Icon/index.tsx index b1951b0e..ba973873 100644 --- a/ui/src/components/Icon/index.tsx +++ b/ui/src/components/Icon/index.tsx @@ -8,15 +8,24 @@ interface IProps { name: string; className?: string; size?: string; + title?: string; onClick?: () => void; } -const Icon: FC = ({ type = 'br', name, className, size, onClick }) => { +const Icon: FC = ({ + type = 'br', + name, + className, + size, + onClick, + title = '', +}) => { return ( ); }; diff --git a/ui/src/components/Operate/index.tsx b/ui/src/components/Operate/index.tsx index 06e9377e..96e94b7a 100644 --- a/ui/src/components/Operate/index.tsx +++ b/ui/src/components/Operate/index.tsx @@ -1,19 +1,22 @@ import { memo, FC } from 'react'; -import { Button } from 'react-bootstrap'; +import { Button, Dropdown } from 'react-bootstrap'; import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Modal } from '@/components'; import { useReportModal, useToast } from '@/hooks'; +import { QuestionOperationReq } from '@/common/interface'; import Share from '../Share'; import { deleteQuestion, deleteAnswer, editCheck, reopenQuestion, + questionOpetation, } from '@/services'; import { tryNormalLogged } from '@/utils/guard'; import { floppyNavigation } from '@/utils'; +import { toastStore } from '@/stores'; interface IProps { type: 'answer' | 'question'; @@ -78,7 +81,7 @@ const Index: FC = ({ id: qid, }).then(() => { toast.onShow({ - msg: t('tip_question_deleted'), + msg: t('post_deleted', { keyPrefix: 'messages' }), variant: 'success', }); callback?.('delete_question'); @@ -134,7 +137,7 @@ const Index: FC = ({ question_id: qid, }).then(() => { toast.onShow({ - msg: t('success', { keyPrefix: 'question_detail.reopen' }), + msg: t('post_reopen', { keyPrefix: 'messages' }), variant: 'success', }); refreshQuestion(); @@ -143,6 +146,51 @@ const Index: FC = ({ }); }; + const handleCommon = async (params) => { + await questionOpetation(params); + let msg = ''; + if (params.operation === 'pin') { + msg = t('post_pin', { keyPrefix: 'messages' }); + } + if (params.operation === 'unpin') { + msg = t('post_unpin', { keyPrefix: 'messages' }); + } + if (params.operation === 'hide') { + msg = t('post_hide_list', { keyPrefix: 'messages' }); + } + if (params.operation === 'show') { + msg = t('post_show_list', { keyPrefix: 'messages' }); + } + toastStore.getState().show({ + msg, + variant: 'success', + }); + setTimeout(() => { + refreshQuestion(); + }, 100); + }; + + const handlOtherActions = (action) => { + const params: QuestionOperationReq = { + id: qid, + operation: action, + }; + + if (action === 'pin') { + Modal.confirm({ + title: t('title', { keyPrefix: 'question_detail.pin' }), + content: t('content', { keyPrefix: 'question_detail.pin' }), + cancelBtnVariant: 'link', + confirmText: t('confirm_btn', { keyPrefix: 'question_detail.pin' }), + onConfirm: () => { + handleCommon(params); + }, + }); + } else { + handleCommon(params); + } + }; + const handleAction = (action) => { if (!tryNormalLogged(true)) { return; @@ -162,8 +210,33 @@ const Index: FC = ({ if (action === 'reopen') { handleReopen(); } + + if ( + action === 'pin' || + action === 'unpin' || + action === 'hide' || + action === 'show' + ) { + handlOtherActions(action); + } }; + const firstAction = + memberActions?.filter( + (v) => + v.action === 'report' || v.action === 'edit' || v.action === 'delete', + ) || []; + const secondAction = + memberActions?.filter( + (v) => + v.action === 'close' || + v.action === 'reopen' || + v.action === 'pin' || + v.action === 'unpin' || + v.action === 'hide' || + v.action === 'show', + ) || []; + return (
= ({ title={title} slugTitle={slugTitle} /> - {memberActions?.map((item) => { + {firstAction?.map((item) => { if (item.action === 'edit') { return ( handleEdit(evt, editUrl)} style={{ lineHeight: '23px' }}> {item.name} @@ -190,12 +263,32 @@ const Index: FC = ({ ); })} + {secondAction.length > 0 && ( + + + {t('action', { keyPrefix: 'question_detail' })} + + + {secondAction.map((item) => { + return ( + handleAction(item.action)}> + {item.name} + + ); + })} + + + )}
); }; diff --git a/ui/src/components/QuestionList/index.tsx b/ui/src/components/QuestionList/index.tsx index 94377b18..26599c79 100644 --- a/ui/src/components/QuestionList/index.tsx +++ b/ui/src/components/QuestionList/index.tsx @@ -14,6 +14,7 @@ import { QueryGroup, QuestionListLoader, Counts, + Icon, } from '@/components'; const QuestionOrderKeys: Type.QuestionOrderBy[] = [ @@ -62,6 +63,13 @@ const QuestionList: FC = ({ source, data, isLoading = false }) => { key={li.id} className="bg-transparent py-3 px-0 border-start-0 border-end-0">
+ {li.pin === 2 && ( + + )} diff --git a/ui/src/components/Share/index.tsx b/ui/src/components/Share/index.tsx index 855b02cb..34c45fc3 100644 --- a/ui/src/components/Share/index.tsx +++ b/ui/src/components/Share/index.tsx @@ -71,7 +71,7 @@ const Index: FC = ({ type, qid, aid, title, slugTitle = '' }) => { setShow(true)} style={{ lineHeight: '23px' }}> {t('share.name')} diff --git a/ui/src/components/TagSelector/index.tsx b/ui/src/components/TagSelector/index.tsx index a8bb0a6c..01961ff5 100644 --- a/ui/src/components/TagSelector/index.tsx +++ b/ui/src/components/TagSelector/index.tsx @@ -38,7 +38,7 @@ const TagSelector: FC = ({ const [initialValue, setInitialValue] = useState([...value]); const [currentIndex, setCurrentIndex] = useState(0); const [repeatIndex, setRepeatIndex] = useState(-1); - const [tag, setTag] = useState(''); + const [searchValue, setSearchValue] = useState(''); const [tags, setTags] = useState(null); const { t } = useTranslation('translation', { keyPrefix: 'tag_selector' }); const [visibleMenu, setVisibleMenu] = useState(false); @@ -101,12 +101,12 @@ const TagSelector: FC = ({ const fetchTags = (str) => { queryTags(str).then((res) => { const tagArray: Type.Tag[] = filterTags(res || []); - setTags(tagArray); + setTags(tagArray?.length > 5 ? tagArray.slice(0, 5) : tagArray); }); }; useEffect(() => { - fetchTags(tag); + fetchTags(searchValue); }, [visibleMenu]); const handleClick = (val: Type.Tag) => { @@ -146,7 +146,7 @@ const TagSelector: FC = ({ const handleSearch = async (e: React.ChangeEvent) => { const searchStr = e.currentTarget.value.replace(';', ''); - setTag(searchStr); + setSearchValue(searchStr); fetchTags(searchStr); }; @@ -172,7 +172,7 @@ const TagSelector: FC = ({ e.preventDefault(); if (tags.length === 0) { - tagModal.onShow(tag); + tagModal.onShow(searchValue); return; } if (currentIndex <= tags.length - 1) { @@ -228,13 +228,14 @@ const TagSelector: FC = ({ )} - {showRequiredTagText && + {!searchValue && + showRequiredTagText && tags && tags.filter((v) => v.recommend)?.length > 0 && (
{t('tag_required_text')}
@@ -251,17 +252,17 @@ const TagSelector: FC = ({ ); })} - {tag && tags && tags.length === 0 && ( + {searchValue && tags && tags.length === 0 && ( {t('no_result')} )} - {!hiddenCreateBtn && tag && ( + {!hiddenCreateBtn && searchValue && ( diff --git a/ui/src/pages/Questions/Detail/components/Question/index.tsx b/ui/src/pages/Questions/Detail/components/Question/index.tsx index 94a42981..3cfb7de0 100644 --- a/ui/src/pages/Questions/Detail/components/Question/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Question/index.tsx @@ -11,6 +11,7 @@ import { Comment, FormatTime, htmlRender, + Icon, } from '@/components'; import { formatCount, guard } from '@/utils'; import { following } from '@/services'; @@ -65,6 +66,13 @@ const Index: FC = ({ data, initPage, hasAnswer, isLogged }) => { return (

+ {data?.pin === 2 && ( + + )} { - + {t('avatar.label')}
{ export const saveQuestionWidthAnaser = (params: Type.QuestionWithAnswer) => { return request.post('/answer/api/v1/question/answer', params); }; + +export const questionOpetation = (params: Type.QuestionOperationReq) => { + return request.put('/answer/api/v1/question/operation', params); +};