Merge remote-tracking branch 'github/feat/1.1.2/ui' into feat/1.1.2/user-center

# Conflicts:
#	Makefile
#	i18n/en_US.yaml
#	internal/migrations/migrations.go
This commit is contained in:
LinkinStars 2023-04-19 17:50:35 +08:00
commit 8fb303d024
32 changed files with 707 additions and 50 deletions

View File

@ -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"
},

View File

@ -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"
},

View File

@ -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:

View File

@ -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 <strong>avoid</strong> ...
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.

View File

@ -22,6 +22,14 @@ backend:
other: 关闭
reopen:
other: 重新打开
pin:
other: 置顶
hide:
other: 隐藏
unpin:
other: 取消置顶
show:
other: 显示
role:
name:
user:

View File

@ -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 (

View File

@ -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 {

View File

@ -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"`

View File

@ -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),
}

118
internal/migrations/v8.go Normal file
View File

@ -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
}

View File

@ -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)

View File

@ -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 {

View File

@ -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)

View File

@ -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"`

View File

@ -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"
)

View File

@ -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",

View File

@ -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" {

View File

@ -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
}

View File

@ -1,6 +1,6 @@
const {
addWebpackModuleRule,
addWebpackAlias
addWebpackAlias,
} = require("customize-cra");
const path = require("path");

View File

@ -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",

View File

@ -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

View File

@ -609,6 +609,10 @@ export const TIMELINE_NORMAL_ACTIVITY_TYPE = [
'upvote',
'reopened',
'closed',
'pin',
'unpin',
'show',
'hide',
];
export const SYSTEM_AVATAR_OPTIONS = [

View File

@ -585,3 +585,8 @@ export interface PluginConfig {
slug_name: string;
config_fields: PluginItem[];
}
export interface QuestionOperationReq {
id: string;
operation: 'pin' | 'unpin' | 'hide' | 'show';
}

View File

@ -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);

View File

@ -8,15 +8,24 @@ interface IProps {
name: string;
className?: string;
size?: string;
title?: string;
onClick?: () => void;
}
const Icon: FC<IProps> = ({ type = 'br', name, className, size, onClick }) => {
const Icon: FC<IProps> = ({
type = 'br',
name,
className,
size,
onClick,
title = '',
}) => {
return (
<i
className={classNames(type, `bi-${name}`, className)}
style={{ ...(size && { fontSize: size }) }}
onClick={onClick}
onKeyDown={onClick}
title={title}
/>
);
};

View File

@ -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<IProps> = ({
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<IProps> = ({
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<IProps> = ({
});
};
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<IProps> = ({
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 (
<div className="d-flex align-items-center">
<Share
@ -173,13 +246,13 @@ const Index: FC<IProps> = ({
title={title}
slugTitle={slugTitle}
/>
{memberActions?.map((item) => {
{firstAction?.map((item) => {
if (item.action === 'edit') {
return (
<Link
key={item.action}
to={editUrl}
className="link-secondary p-0 fs-14 me-3"
className="link-secondary p-0 fs-14 ms-3"
onClick={(evt) => handleEdit(evt, editUrl)}
style={{ lineHeight: '23px' }}>
{item.name}
@ -190,12 +263,32 @@ const Index: FC<IProps> = ({
<Button
key={item.action}
variant="link"
className="link-secondary p-0 fs-14 me-3"
className="link-secondary p-0 fs-14 ms-3"
onClick={() => handleAction(item.action)}>
{item.name}
</Button>
);
})}
{secondAction.length > 0 && (
<Dropdown className="ms-3">
<Dropdown.Toggle
variant="link"
className="link-secondary p-0 fs-14 no-toggle">
{t('action', { keyPrefix: 'question_detail' })}
</Dropdown.Toggle>
<Dropdown.Menu>
{secondAction.map((item) => {
return (
<Dropdown.Item
key={item.action}
onClick={() => handleAction(item.action)}>
{item.name}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
)}
</div>
);
};

View File

@ -14,6 +14,7 @@ import {
QueryGroup,
QuestionListLoader,
Counts,
Icon,
} from '@/components';
const QuestionOrderKeys: Type.QuestionOrderBy[] = [
@ -62,6 +63,13 @@ const QuestionList: FC<Props> = ({ source, data, isLoading = false }) => {
key={li.id}
className="bg-transparent py-3 px-0 border-start-0 border-end-0">
<h5 className="text-wrap text-break">
{li.pin === 2 && (
<Icon
name="pin-fill"
className="me-1"
title={t('pinned', { keyPrefix: 'btns' })}
/>
)}
<NavLink
to={pathFactory.questionLanding(li.id, li.url_title)}
className="link-dark">

View File

@ -71,7 +71,7 @@ const Index: FC<IProps> = ({ type, qid, aid, title, slugTitle = '' }) => {
<Dropdown.Toggle
id="dropdown-share"
as="a"
className="no-toggle fs-14 link-secondary pointer me-3"
className="no-toggle fs-14 link-secondary pointer"
onClick={() => setShow(true)}
style={{ lineHeight: '23px' }}>
{t('share.name')}

View File

@ -38,7 +38,7 @@ const TagSelector: FC<IProps> = ({
const [initialValue, setInitialValue] = useState<Type.Tag[]>([...value]);
const [currentIndex, setCurrentIndex] = useState<number>(0);
const [repeatIndex, setRepeatIndex] = useState(-1);
const [tag, setTag] = useState<string>('');
const [searchValue, setSearchValue] = useState<string>('');
const [tags, setTags] = useState<Type.Tag[] | null>(null);
const { t } = useTranslation('translation', { keyPrefix: 'tag_selector' });
const [visibleMenu, setVisibleMenu] = useState(false);
@ -101,12 +101,12 @@ const TagSelector: FC<IProps> = ({
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<IProps> = ({
const handleSearch = async (e: React.ChangeEvent<HTMLInputElement>) => {
const searchStr = e.currentTarget.value.replace(';', '');
setTag(searchStr);
setSearchValue(searchStr);
fetchTags(searchStr);
};
@ -172,7 +172,7 @@ const TagSelector: FC<IProps> = ({
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<IProps> = ({
<FormControl
placeholder={t('search_tag')}
autoFocus
value={tag}
value={searchValue}
onChange={handleSearch}
/>
</Form>
</Dropdown.Header>
)}
{showRequiredTagText &&
{!searchValue &&
showRequiredTagText &&
tags &&
tags.filter((v) => v.recommend)?.length > 0 && (
<h6 className="dropdown-header">{t('tag_required_text')}</h6>
@ -251,17 +252,17 @@ const TagSelector: FC<IProps> = ({
</Dropdown.Item>
);
})}
{tag && tags && tags.length === 0 && (
{searchValue && tags && tags.length === 0 && (
<Dropdown.Item disabled className="text-secondary">
{t('no_result')}
</Dropdown.Item>
)}
{!hiddenCreateBtn && tag && (
{!hiddenCreateBtn && searchValue && (
<Button
variant="link"
className="px-3 btn-no-border w-100 text-start"
onClick={() => {
tagModal.onShow(tag);
tagModal.onShow(searchValue);
}}>
+ {t('create_btn')}
</Button>

View File

@ -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<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
return (
<div>
<h1 className="h3 mb-3 text-wrap text-break">
{data?.pin === 2 && (
<Icon
name="pin-fill"
className="me-1"
title={t('pinned', { keyPrefix: 'btns' })}
/>
)}
<Link
className="link-dark"
reloadDocument

View File

@ -324,7 +324,7 @@ const Index: React.FC = () => {
</Form.Control.Feedback>
</Form.Group>
<Form.Group className="mb-3">
<Form.Group controlId="avatar" className="mb-3">
<Form.Label>{t('avatar.label')}</Form.Label>
<div className="mb-3">
<Form.Select

View File

@ -278,3 +278,7 @@ export const markdownToHtml = (content: string) => {
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);
};