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": { "/answer/api/v1/question/page": {
"get": { "get": {
"description": "get questions by page", "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": { "schema.PermissionMemberAction": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -7611,6 +7665,14 @@ const docTemplate = `{
"operator": { "operator": {
"$ref": "#/definitions/schema.QuestionPageRespOperator" "$ref": "#/definitions/schema.QuestionPageRespOperator"
}, },
"pin": {
"description": "1: unpin, 2: pin",
"type": "integer"
},
"show": {
"description": "0: show, 1: hide",
"type": "integer"
},
"status": { "status": {
"type": "integer" "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": { "/answer/api/v1/question/page": {
"get": { "get": {
"description": "get questions by page", "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": { "schema.PermissionMemberAction": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -7599,6 +7653,14 @@
"operator": { "operator": {
"$ref": "#/definitions/schema.QuestionPageRespOperator" "$ref": "#/definitions/schema.QuestionPageRespOperator"
}, },
"pin": {
"description": "1: unpin, 2: pin",
"type": "integer"
},
"show": {
"description": "0: show, 1: hide",
"type": "integer"
},
"status": { "status": {
"type": "integer" "type": "integer"
}, },

View File

@ -1128,6 +1128,16 @@ definitions:
description: inbox achievement description: inbox achievement
type: string type: string
type: object type: object
schema.OperationQuestionReq:
properties:
id:
type: string
operation:
description: operation [pin unpin hide show]
type: string
required:
- id
type: object
schema.PermissionMemberAction: schema.PermissionMemberAction:
properties: properties:
action: action:
@ -1249,6 +1259,12 @@ definitions:
type: string type: string
operator: operator:
$ref: '#/definitions/schema.QuestionPageRespOperator' $ref: '#/definitions/schema.QuestionPageRespOperator'
pin:
description: '1: unpin, 2: pin'
type: integer
show:
description: '0: show, 1: hide'
type: integer
status: status:
type: integer type: integer
tags: tags:
@ -4405,6 +4421,30 @@ paths:
summary: get question details summary: get question details
tags: tags:
- Question - 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: /answer/api/v1/question/page:
get: get:
consumes: consumes:

View File

@ -25,6 +25,14 @@ backend:
other: Reopen other: Reopen
forbidden_error: forbidden_error:
other: Forbidden. other: Forbidden.
pin:
other: Pin
hide:
other: Unlist
unpin:
other: Unpin
show:
other: List
role: role:
name: name:
user: user:
@ -868,6 +876,7 @@ ui:
btn: Add question btn: Add question
answers: answers answers: answers
question_detail: question_detail:
action: Action
Asked: Asked Asked: Asked
asked: asked asked: asked
update: Modified update: Modified
@ -906,13 +915,14 @@ ui:
li1_2: Back up any statements you make with references or personal experience. li1_2: Back up any statements you make with references or personal experience.
header_2: But <strong>avoid</strong> ... header_2: But <strong>avoid</strong> ...
li2_1: Asking for help, seeking clarification, or responding to other answers. li2_1: Asking for help, seeking clarification, or responding to other answers.
reopen: reopen:
confirm_btn: Reopen confirm_btn: Reopen
title: Reopen this post title: Reopen this post
content: Are you sure you want to reopen? 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: delete:
title: Delete this post title: Delete this post
question: >- question: >-
@ -926,7 +936,6 @@ ui:
of accepted answers can result in your account being blocked from answering. of accepted answers can result in your account being blocked from answering.
Are you sure you wish to delete? Are you sure you wish to delete?
other: 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 tip_answer_deleted: This answer has been deleted
btns: btns:
confirm: Confirm confirm: Confirm
@ -942,6 +951,7 @@ ui:
reject: Reject reject: Reject
skip: Skip skip: Skip
discard_draft: Discard draft discard_draft: Discard draft
pinned: Pinned
search: search:
title: Search Results title: Search Results
keywords: Keywords keywords: Keywords
@ -1574,6 +1584,10 @@ ui:
closed: closed closed: closed
reopened: reopened reopened: reopened
created: created created: created
pin: pinned
unpin: unpinned
show: listed
hide: unlisted
title: "History for" title: "History for"
tag_title: "Timeline for" tag_title: "Timeline for"
show_votes: "Show votes" show_votes: "Show votes"
@ -1599,5 +1613,9 @@ ui:
draft: draft:
discard_confirm: Are you sure you want to discard your draft? discard_confirm: Are you sure you want to discard your draft?
messages: 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: 关闭 other: 关闭
reopen: reopen:
other: 重新打开 other: 重新打开
pin:
other: 置顶
hide:
other: 隐藏
unpin:
other: 取消置顶
show:
other: 显示
role: role:
name: name:
user: user:

View File

@ -14,6 +14,10 @@ const (
ActFollow = "follow" ActFollow = "follow"
ActAccepted = "accepted" ActAccepted = "accepted"
ActAccept = "accept" ActAccept = "accept"
ActPin = "pin"
ActUnPin = "unpin"
ActShow = "show"
ActHide = "hide"
) )
const ( const (
@ -29,6 +33,10 @@ const (
ActQuestionRollback ActivityTypeKey = "question.rollback" ActQuestionRollback ActivityTypeKey = "question.rollback"
ActQuestionDeleted ActivityTypeKey = "question.deleted" ActQuestionDeleted ActivityTypeKey = "question.deleted"
ActQuestionUndeleted ActivityTypeKey = "question.undeleted" ActQuestionUndeleted ActivityTypeKey = "question.undeleted"
ActQuestionPin ActivityTypeKey = "question.pin"
ActQuestionUnPin ActivityTypeKey = "question.unpin"
ActQuestionHide ActivityTypeKey = "question.hide"
ActQuestionShow ActivityTypeKey = "question.show"
) )
const ( const (

View File

@ -70,6 +70,47 @@ func (qc *QuestionController) RemoveQuestion(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil) 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 // CloseQuestion Close question
// @Summary Close question // @Summary Close question
// @Description Close question // @Description Close question
@ -152,6 +193,10 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
permission.QuestionDelete, permission.QuestionDelete,
permission.QuestionClose, permission.QuestionClose,
permission.QuestionReopen, permission.QuestionReopen,
permission.QuestionPin,
permission.QuestionUnPin,
permission.QuestionHide,
permission.QuestionShow,
}) })
if err != nil { if err != nil {
handler.HandleResponse(ctx, err, nil) handler.HandleResponse(ctx, err, nil)
@ -163,6 +208,10 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
req.CanDelete = canList[1] req.CanDelete = canList[1]
req.CanClose = canList[2] req.CanClose = canList[2]
req.CanReopen = canList[3] 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) info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req)
if err != nil { if err != nil {

View File

@ -8,6 +8,10 @@ const (
QuestionStatusAvailable = 1 QuestionStatusAvailable = 1
QuestionStatusClosed = 2 QuestionStatusClosed = 2
QuestionStatusDeleted = 10 QuestionStatusDeleted = 10
QuestionUnPin = 1
QuestionPin = 2
QuestionShow = 1
QuestionHide = 2
) )
var AdminQuestionSearchStatus = map[string]int{ var AdminQuestionSearchStatus = map[string]int{
@ -32,6 +36,8 @@ type Question struct {
Title string `xorm:"not null default '' VARCHAR(150) title"` Title string `xorm:"not null default '' VARCHAR(150) title"`
OriginalText string `xorm:"not null MEDIUMTEXT original_text"` OriginalText string `xorm:"not null MEDIUMTEXT original_text"`
ParsedText string `xorm:"not null MEDIUMTEXT parsed_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"` Status int `xorm:"not null default 1 INT(11) status"`
ViewCount int `xorm:"not null default 0 INT(11) view_count"` ViewCount int `xorm:"not null default 0 INT(11) view_count"`
UniqueViewCount int `xorm:"not null default 0 INT(11) unique_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 user role", addRoleFeatures, false),
NewMigration("add theme and private mode", addThemeAndPrivateMode, true), NewMigration("add theme and private mode", addThemeAndPrivateMode, true),
NewMigration("add new answer notification", addNewAnswerNotification, true), NewMigration("add new answer notification", addNewAnswerNotification, true),
NewMigration("add user pin hide features", addRolePinAndHideFeatures, true),
NewMigration("add plugin", addPlugin, false), NewMigration("add plugin", addPlugin, false),
NewMigration("add login limitations", addLoginLimitations, true), 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 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) { func (qr *questionRepo) UpdateAccepted(ctx context.Context, question *entity.Question) (err error) {
question.ID = uid.DeShortID(question.ID) question.ID = uid.DeShortID(question.ID)
_, err = qr.data.DB.Where("id =?", question.ID).Cols("accepted_answer_id").Update(question) _, 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 offset := page * pageSize
session := qr.data.DB.Table("question") session := qr.data.DB.Table("question")
session = session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed}) session = session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed})
session.And("question.show = ?", entity.QuestionShow)
session = session.Limit(pageSize, offset) session = session.Limit(pageSize, offset)
session = session.OrderBy("question.created_at asc") session = session.OrderBy("question.created_at asc")
err = session.Select("id,title,created_at,post_update_time").Find(&rows) 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 { if len(userID) > 0 {
session.And("question.user_id = ?", userID) session.And("question.user_id = ?", userID)
} else {
session.And("question.show = ?", entity.QuestionShow)
} }
switch orderCond { switch orderCond {
case "newest": case "newest":
session.OrderBy("question.created_at DESC") session.OrderBy("question.pin desc,question.created_at DESC")
case "active": 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": case "frequent":
session.OrderBy("question.view_count DESC") session.OrderBy("question.pin desc,question.view_count DESC")
case "score": 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": case "unanswered":
session.Where("question.last_answer_id = 0") 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) 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`"). ub = builder.MySQL().Select(afs...).From("`answer`").
LeftJoin("`question`", "`question`.id = `answer`.question_id") 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}). 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) argsQ = append(argsQ, entity.QuestionStatusDeleted, entity.QuestionShow)
argsA = append(argsA, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted) argsA = append(argsA, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow)
for i, word := range words { for i, word := range words {
if i == 0 { 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 := builder.MySQL().Select(qfs...).From("question")
b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}) b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow})
args = append(args, entity.QuestionStatusDeleted) args = append(args, entity.QuestionStatusDeleted, entity.QuestionShow)
for i, word := range words { for i, word := range words {
if i == 0 { if i == 0 {
@ -343,8 +345,8 @@ func (sr *searchRepo) SearchAnswers(ctx context.Context, words []string, tagIDs
LeftJoin("`question`", "`question`.id = `answer`.question_id") 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.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}) And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow})
args = append(args, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted) args = append(args, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow)
for i, word := range words { for i, word := range words {
if i == 0 { if i == 0 {

View File

@ -195,6 +195,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
r.PUT("/question", a.questionController.UpdateQuestion) r.PUT("/question", a.questionController.UpdateQuestion)
r.DELETE("/question", a.questionController.RemoveQuestion) r.DELETE("/question", a.questionController.RemoveQuestion)
r.PUT("/question/status", a.questionController.CloseQuestion) r.PUT("/question/status", a.questionController.CloseQuestion)
r.PUT("/question/operation", a.questionController.OperationQuestion)
r.PUT("/question/reopen", a.questionController.ReopenQuestion) r.PUT("/question/reopen", a.questionController.ReopenQuestion)
r.GET("/question/similar", a.questionController.SearchByTitleLike) r.GET("/question/similar", a.questionController.SearchByTitleLike)

View File

@ -8,9 +8,13 @@ import (
) )
const ( const (
SitemapMaxSize = 50000 SitemapMaxSize = 50000
SitemapCachekey = "answer@sitemap" SitemapCachekey = "answer@sitemap"
SitemapPageCachekey = "answer@sitemap@page%d" SitemapPageCachekey = "answer@sitemap@page%d"
QuestionOperationPin = "pin"
QuestionOperationUnPin = "unpin"
QuestionOperationHide = "hide"
QuestionOperationShow = "show"
) )
// RemoveQuestionReq delete question request // RemoveQuestionReq delete question request
@ -28,6 +32,14 @@ type CloseQuestionReq struct {
UserID string `json:"-"` // user_id 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 { type CloseQuestionMeta struct {
CloseType int `json:"close_type"` CloseType int `json:"close_type"`
CloseMsg string `json:"close_msg"` CloseMsg string `json:"close_msg"`
@ -101,6 +113,12 @@ type QuestionPermission struct {
CanClose bool `json:"-"` CanClose bool `json:"-"`
// whether user can reopen it // whether user can reopen it
CanReopen bool `json:"-"` 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 // whether user can use reserved it
CanUseReservedTag bool `json:"-"` CanUseReservedTag bool `json:"-"`
} }
@ -168,6 +186,8 @@ type QuestionInfo struct {
UpdateTime int64 `json:"-"` // update_time UpdateTime int64 `json:"-"` // update_time
PostUpdateTime int64 `json:"update_time"` PostUpdateTime int64 `json:"update_time"`
QuestionUpdateTime int64 `json:"edit_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"` Status int `json:"status"`
Operation *Operation `json:"operation,omitempty"` Operation *Operation `json:"operation,omitempty"`
UserID string `json:"-" ` UserID string `json:"-" `
@ -295,6 +315,8 @@ type QuestionPageResp struct {
Title string `json:"title"` Title string `json:"title"`
UrlTitle string `json:"url_title"` UrlTitle string `json:"url_title"`
Description string `json:"description"` Description string `json:"description"`
Pin int `json:"pin"` // 1: unpin, 2: pin
Show int `json:"show"` // 0: show, 1: hide
Status int `json:"status"` Status int `json:"status"`
Tags []*TagResp `json:"tags"` Tags []*TagResp `json:"tags"`

View File

@ -10,6 +10,10 @@ const (
QuestionReopen = "question.reopen" QuestionReopen = "question.reopen"
QuestionVoteUp = "question.vote_up" QuestionVoteUp = "question.vote_up"
QuestionVoteDown = "question.vote_down" 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" AnswerAdd = "answer.add"
AnswerEdit = "answer.edit" AnswerEdit = "answer.edit"
AnswerEditWithoutReview = "answer.edit_without_review" AnswerEditWithoutReview = "answer.edit_without_review"
@ -43,4 +47,8 @@ const (
deleteActionName = "action.delete" deleteActionName = "action.delete"
closeActionName = "action.close" closeActionName = "action.close"
reopenActionName = "action.reopen" 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 // GetQuestionPermission get question permission
func GetQuestionPermission(ctx context.Context, userID string, creatorUserID string, 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) { actions []*schema.PermissionMemberAction) {
lang := handler.GetLangByCtx(ctx) lang := handler.GetLangByCtx(ctx)
actions = make([]*schema.PermissionMemberAction, 0) actions = make([]*schema.PermissionMemberAction, 0)
@ -42,6 +42,36 @@ func GetQuestionPermission(ctx context.Context, userID string, creatorUserID str
Type: "confirm", 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 { if canDelete || userID == creatorUserID {
actions = append(actions, &schema.PermissionMemberAction{ actions = append(actions, &schema.PermissionMemberAction{
Action: "delete", Action: "delete",

View File

@ -36,6 +36,7 @@ type QuestionRepo interface {
questionList []*entity.Question, total int64, err error) questionList []*entity.Question, total int64, err error)
UpdateQuestionStatus(ctx context.Context, question *entity.Question) (err error) UpdateQuestionStatus(ctx context.Context, question *entity.Question) (err error)
UpdateQuestionStatusWithOutUpdateTime(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) SearchByTitleLike(ctx context.Context, title string) (questionList []*entity.Question, err error)
UpdatePvCount(ctx context.Context, questionID string) (err error) UpdatePvCount(ctx context.Context, questionID string) (err error)
UpdateAnswerCount(ctx context.Context, questionID string, num int) (err error) UpdateAnswerCount(ctx context.Context, questionID string, num int) (err error)
@ -271,6 +272,8 @@ func (qs *QuestionCommon) FormatQuestionsPage(
FollowCount: questionInfo.FollowCount, FollowCount: questionInfo.FollowCount,
AcceptedAnswerID: questionInfo.AcceptedAnswerID, AcceptedAnswerID: questionInfo.AcceptedAnswerID,
LastAnswerID: questionInfo.LastAnswerID, LastAnswerID: questionInfo.LastAnswerID,
Pin: questionInfo.Pin,
Show: questionInfo.Show,
} }
questionIDs = append(questionIDs, questionInfo.ID) questionIDs = append(questionIDs, questionInfo.ID)
@ -526,6 +529,8 @@ func (qs *QuestionCommon) ShowFormat(ctx context.Context, data *entity.Question)
info.QuestionUpdateTime = 0 info.QuestionUpdateTime = 0
} }
info.Status = data.Status info.Status = data.Status
info.Pin = data.Pin
info.Show = data.Show
info.UserID = data.UserID info.UserID = data.UserID
info.LastEditUserID = data.LastEditUserID info.LastEditUserID = data.LastEditUserID
if data.LastAnswerID != "0" { 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.Status = entity.QuestionStatusAvailable
question.RevisionID = "0" question.RevisionID = "0"
question.CreatedAt = now question.CreatedAt = now
question.Pin = entity.QuestionUnPin
question.Show = entity.QuestionShow
//question.UpdatedAt = nil //question.UpdatedAt = nil
err = qs.questionRepo.AddQuestion(ctx, question) err = qs.questionRepo.AddQuestion(ctx, question)
if err != nil { if err != nil {
@ -319,6 +321,58 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question
return 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 // RemoveQuestion delete question
func (qs *QuestionService) RemoveQuestion(ctx context.Context, req *schema.RemoveQuestionReq) (err error) { func (qs *QuestionService) RemoveQuestion(ctx context.Context, req *schema.RemoveQuestionReq) (err error) {
questionInfo, has, err := qs.questionRepo.GetQuestion(ctx, req.ID) 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 { if question.Status == entity.QuestionStatusClosed {
per.CanClose = false 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 { if question.Status == entity.QuestionStatusDeleted {
operation := &schema.Operation{} operation := &schema.Operation{}
operation.Msg = translator.Tr(handler.GetLangByCtx(ctx), reason.QuestionAlreadyDeleted) 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.Description = htmltext.FetchExcerpt(question.HTML, "...", 240)
question.MemberActions = permission.GetQuestionPermission(ctx, userID, question.UserID, 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 return question, nil
} }
@ -735,14 +804,15 @@ func (qs *QuestionService) SearchUserAnswerList(ctx context.Context, userName, o
if ok { if ok {
item.QuestionInfo = questionMaps[item.QuestionID] item.QuestionInfo = questionMaps[item.QuestionID]
} }
}
for _, item := range answerlist {
info := &schema.UserAnswerInfo{} info := &schema.UserAnswerInfo{}
_ = copier.Copy(info, item) _ = copier.Copy(info, item)
info.AnswerID = item.ID info.AnswerID = item.ID
info.QuestionID = item.QuestionID info.QuestionID = item.QuestionID
userAnswerlist = append(userAnswerlist, info) if item.QuestionInfo.Status != entity.QuestionStatusDeleted {
userAnswerlist = append(userAnswerlist, info)
}
} }
return userAnswerlist, count, nil return userAnswerlist, count, nil
} }

View File

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

View File

@ -82,7 +82,7 @@
"react-app-rewired": "^2.2.1", "react-app-rewired": "^2.2.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"sass": "^1.54.4", "sass": "^1.54.4",
"typescript": "^4.9.5", "typescript": "^4.8.3",
"yaml-loader": "^0.8.0" "yaml-loader": "^0.8.0"
}, },
"packageManager": "pnpm@7.9.5", "packageManager": "pnpm@7.9.5",

View File

@ -66,7 +66,7 @@ specifiers:
sass: ^1.54.4 sass: ^1.54.4
semver: ^7.3.8 semver: ^7.3.8
swr: ^1.3.0 swr: ^1.3.0
typescript: ^4.9.5 typescript: ^4.8.3
urlcat: ^3.0.0 urlcat: ^3.0.0
yaml-loader: ^0.8.0 yaml-loader: ^0.8.0
zustand: ^4.1.1 zustand: ^4.1.1

View File

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

View File

@ -585,3 +585,8 @@ export interface PluginConfig {
slug_name: string; slug_name: string;
config_fields: PluginItem[]; 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) => { scriptList?.forEach((so) => {
const script = document.createElement('script'); const script = document.createElement('script');
script.text = so.text; script.text = `(() => {
${so.text}
})();`;
for (let i = 0; i < so.attributes.length; i += 1) { for (let i = 0; i < so.attributes.length; i += 1) {
const attr = so.attributes[i]; const attr = so.attributes[i];
script.setAttribute(attr.name, attr.value); script.setAttribute(attr.name, attr.value);

View File

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

View File

@ -1,19 +1,22 @@
import { memo, FC } from 'react'; import { memo, FC } from 'react';
import { Button } from 'react-bootstrap'; import { Button, Dropdown } from 'react-bootstrap';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Modal } from '@/components'; import { Modal } from '@/components';
import { useReportModal, useToast } from '@/hooks'; import { useReportModal, useToast } from '@/hooks';
import { QuestionOperationReq } from '@/common/interface';
import Share from '../Share'; import Share from '../Share';
import { import {
deleteQuestion, deleteQuestion,
deleteAnswer, deleteAnswer,
editCheck, editCheck,
reopenQuestion, reopenQuestion,
questionOpetation,
} from '@/services'; } from '@/services';
import { tryNormalLogged } from '@/utils/guard'; import { tryNormalLogged } from '@/utils/guard';
import { floppyNavigation } from '@/utils'; import { floppyNavigation } from '@/utils';
import { toastStore } from '@/stores';
interface IProps { interface IProps {
type: 'answer' | 'question'; type: 'answer' | 'question';
@ -78,7 +81,7 @@ const Index: FC<IProps> = ({
id: qid, id: qid,
}).then(() => { }).then(() => {
toast.onShow({ toast.onShow({
msg: t('tip_question_deleted'), msg: t('post_deleted', { keyPrefix: 'messages' }),
variant: 'success', variant: 'success',
}); });
callback?.('delete_question'); callback?.('delete_question');
@ -134,7 +137,7 @@ const Index: FC<IProps> = ({
question_id: qid, question_id: qid,
}).then(() => { }).then(() => {
toast.onShow({ toast.onShow({
msg: t('success', { keyPrefix: 'question_detail.reopen' }), msg: t('post_reopen', { keyPrefix: 'messages' }),
variant: 'success', variant: 'success',
}); });
refreshQuestion(); 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) => { const handleAction = (action) => {
if (!tryNormalLogged(true)) { if (!tryNormalLogged(true)) {
return; return;
@ -162,8 +210,33 @@ const Index: FC<IProps> = ({
if (action === 'reopen') { if (action === 'reopen') {
handleReopen(); 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 ( return (
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<Share <Share
@ -173,13 +246,13 @@ const Index: FC<IProps> = ({
title={title} title={title}
slugTitle={slugTitle} slugTitle={slugTitle}
/> />
{memberActions?.map((item) => { {firstAction?.map((item) => {
if (item.action === 'edit') { if (item.action === 'edit') {
return ( return (
<Link <Link
key={item.action} key={item.action}
to={editUrl} 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)} onClick={(evt) => handleEdit(evt, editUrl)}
style={{ lineHeight: '23px' }}> style={{ lineHeight: '23px' }}>
{item.name} {item.name}
@ -190,12 +263,32 @@ const Index: FC<IProps> = ({
<Button <Button
key={item.action} key={item.action}
variant="link" variant="link"
className="link-secondary p-0 fs-14 me-3" className="link-secondary p-0 fs-14 ms-3"
onClick={() => handleAction(item.action)}> onClick={() => handleAction(item.action)}>
{item.name} {item.name}
</Button> </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> </div>
); );
}; };

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {
Comment, Comment,
FormatTime, FormatTime,
htmlRender, htmlRender,
Icon,
} from '@/components'; } from '@/components';
import { formatCount, guard } from '@/utils'; import { formatCount, guard } from '@/utils';
import { following } from '@/services'; import { following } from '@/services';
@ -65,6 +66,13 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => {
return ( return (
<div> <div>
<h1 className="h3 mb-3 text-wrap text-break"> <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 <Link
className="link-dark" className="link-dark"
reloadDocument reloadDocument

View File

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

View File

@ -278,3 +278,7 @@ export const markdownToHtml = (content: string) => {
export const saveQuestionWidthAnaser = (params: Type.QuestionWithAnswer) => { export const saveQuestionWidthAnaser = (params: Type.QuestionWithAnswer) => {
return request.post('/answer/api/v1/question/answer', params); return request.post('/answer/api/v1/question/answer', params);
}; };
export const questionOpetation = (params: Type.QuestionOperationReq) => {
return request.put('/answer/api/v1/question/operation', params);
};