This is an automated email from the ASF dual-hosted git repository. linkinstar pushed a commit to branch dev in repository https://gitbox.apache.org/repos/asf/incubator-answer.git
commit 3eb9822c0906aa67a08a62f03bb9b10b841b420f Author: Sonui <[email protected]> AuthorDate: Fri Sep 27 23:42:28 2024 +0800 feat(question): support linking question --- docs/docs.go | 84 +++++++++++ docs/swagger.json | 84 +++++++++++ docs/swagger.yaml | 51 +++++++ i18n/en_US.yaml | 6 +- i18n/zh_CN.yaml | 5 +- internal/controller/question_controller.go | 22 +++ .../entity/question_link_entity.go | 42 +++--- internal/migrations/init_data.go | 1 + internal/migrations/migrations.go | 1 + .../index.tsx => internal/migrations/v23.go | 29 ++-- internal/repo/answer/answer_repo.go | 18 +++ internal/repo/question/question_repo.go | 167 +++++++++++++++++++++ internal/router/answer_api_router.go | 1 + internal/schema/question_schema.go | 14 ++ internal/service/answer_common/answer.go | 1 + internal/service/content/answer_service.go | 114 +++++++++++++- internal/service/content/question_service.go | 145 +++++++++++++++++- internal/service/question_common/question.go | 5 + pkg/checker/question_link.go | 107 +++++++++++++ pkg/checker/question_link_test.go | 141 +++++++++++++++++ ui/src/components/QuestionList/index.tsx | 2 +- .../Detail/components/LinkedQuestions/index.tsx | 126 ++++++++++++++++ ui/src/pages/Questions/Detail/components/index.tsx | 2 + ui/src/pages/Questions/Detail/index.tsx | 3 + ui/src/pages/Questions/Linked/index.tsx | 95 ++++++++++++ ui/src/router/routes.ts | 4 + ui/src/services/client/question.ts | 17 +++ 27 files changed, 1245 insertions(+), 42 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 80f4a0d8..424bd669 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4462,6 +4462,90 @@ const docTemplate = `{ } } }, + "/answer/api/v1/question/link": { + "get": { + "description": "get question link", + "tags": [ + "Question" + ], + "summary": "get question link", + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "in_days", + "in": "query" + }, + { + "enum": [ + "newest", + "active", + "hot", + "score", + "unanswered", + "recommend" + ], + "type": "string", + "name": "order", + "in": "query" + }, + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "name": "question_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.QuestionPageResp" + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/question/operation": { "put": { "security": [ diff --git a/docs/swagger.json b/docs/swagger.json index 5690e67b..7ec493b2 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4435,6 +4435,90 @@ } } }, + "/answer/api/v1/question/link": { + "get": { + "description": "get question link", + "tags": [ + "Question" + ], + "summary": "get question link", + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "in_days", + "in": "query" + }, + { + "enum": [ + "newest", + "active", + "hot", + "score", + "unanswered", + "recommend" + ], + "type": "string", + "name": "order", + "in": "query" + }, + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "name": "question_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.QuestionPageResp" + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, "/answer/api/v1/question/operation": { "put": { "security": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6a0ab449..4622e964 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -5586,6 +5586,57 @@ paths: summary: update question invite user tags: - Question + /answer/api/v1/question/link: + get: + description: get question link + parameters: + - in: query + minimum: 1 + name: in_days + type: integer + - enum: + - newest + - active + - hot + - score + - unanswered + - recommend + in: query + name: order + type: string + - in: query + minimum: 1 + name: page + type: integer + - in: query + maximum: 100 + minimum: 1 + name: page_size + type: integer + - in: query + name: question_id + required: true + type: string + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + allOf: + - $ref: '#/definitions/pager.PageModel' + - properties: + list: + items: + $ref: '#/definitions/schema.QuestionPageResp' + type: array + type: object + type: object + summary: get question link + tags: + - Question /answer/api/v1/question/operation: put: consumes: diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index dea08198..f47bca72 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -789,7 +789,8 @@ ui: how_to_format: title: How to Format desc: >- - <ul class="mb-0"><li><p class="mb-2">to make links</p><pre + <ul class="mb-0"><li><p class="mb-2">link question or answer: <code>#10010000000000001</code></p></li> + <li><p class="mb-2">to make links</p><pre class="mb-2"><code><https://url.com><br/><br/>[Title](https://url.com)</code></pre></li><li><p class="mb-2">put returns between paragraphs</p></li><li><p class="mb-2"><em>_italic_</em> or **<strong>bold</strong>**</p></li><li><p @@ -1359,6 +1360,9 @@ ui: related_question: title: Related Questions answers: answers + linked_question: + title: Linked Questions + no_linked_question: No questions linked from this question. invite_to_answer: title: Invite People desc: Invite people you think can answer. diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index 889d4da2..f6777d89 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -794,7 +794,7 @@ ui: how_to_format: title: 如何排版 desc: >- - <ul class="mb-0"><li><p class="mb-2">添加链接</p><pre class="mb-2"><code><https://url.com><br/><br/>[标题](https://url.com)</code></pre></li><li><p class="mb-2">段落之间使用空行分隔</p></li><li><p class="mb-2"><em>_斜体_</em> 或者 **<strong>粗体</strong>**</p></li><li><p class="mb-2">使用 4 个空格缩进代码</p></li><li><p class="mb-2">在行首添加 <code>></code> 表示引用</p></li><li><p class="mb-2">反引号进行转义 <code>`像 _这样_`</code></p></li><li><p class="mb-2">使用 <code>```</code> 创建代码块</p><pre class="mb-0"><code>```<br/> [...] + <ul class="mb-0"><li><p class="mb-2">引用问题或答案: <code>#10010000000000001</code></p></li><li><p class="mb-2">添加链接</p><pre class="mb-2"><code><https://url.com><br/><br/>[标题](https://url.com)</code></pre></li><li><p class="mb-2">段落之间使用空行分隔</p></li><li><p class="mb-2"><em>_斜体_</em> 或者 **<strong>粗体</strong>**</p></li><li><p class="mb-2">使用 4 个空格缩进代码</p></li><li><p class="mb-2">在行首添加 <code>></code> 表示引用</p></li><li><p class="mb-2">反引号进行转义 <code>`像 _这样_`</code></p></li><li><p class [...] pagination: prev: 上一页 next: 下一页 @@ -1341,6 +1341,9 @@ ui: related_question: title: 相关问题 answers: 个回答 + linked_question: + title: 关联问题 + no_linked_question: 没有关联的问题。 invite_to_answer: title: 受邀人 desc: 邀请你认为可能知道答案的人。 diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 5ddc33f3..8f349855 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -974,3 +974,25 @@ func (qc *QuestionController) AdminUpdateQuestionStatus(ctx *gin.Context) { err := qc.questionService.AdminSetQuestionStatus(ctx, req) handler.HandleResponse(ctx, err, nil) } + +// GetQuestionLink get question link +// @Summary get question link +// @Description get question link +// @Tags Question +// @Param data query schema.GetQuestionLinkReq true "GetQuestionLinkReq" +// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.QuestionPageResp}} +// @Router /answer/api/v1/question/link [get] +func (qc *QuestionController) GetQuestionLink(ctx *gin.Context) { + req := &schema.GetQuestionLinkReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx) + req.QuestionID = uid.DeShortID(req.QuestionID) + questions, total, err := qc.questionService.GetQuestionLink(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, questions)) +} diff --git a/ui/src/pages/Questions/Detail/components/index.tsx b/internal/entity/question_link_entity.go similarity index 50% copy from ui/src/pages/Questions/Detail/components/index.tsx copy to internal/entity/question_link_entity.go index 1cfe75ee..825e7595 100644 --- a/ui/src/pages/Questions/Detail/components/index.tsx +++ b/internal/entity/question_link_entity.go @@ -17,22 +17,28 @@ * under the License. */ -import Question from './Question'; -import Answer from './Answer'; -import AnswerHead from './AnswerHead'; -import RelatedQuestions from './RelatedQuestions'; -import WriteAnswer from './WriteAnswer'; -import Alert from './Alert'; -import ContentLoader from './ContentLoader'; -import InviteToAnswer from './InviteToAnswer'; +package entity -export { - Question, - Answer, - AnswerHead, - RelatedQuestions, - WriteAnswer, - Alert, - ContentLoader, - InviteToAnswer, -}; +import ( + "time" +) + +const ( + QuestionLinkStatusAvailable = 1 + QuestionLinkStatusDeleted = 2 +) + +type QuestionLink struct { + ID string `xorm:"not null pk autoincr BIGINT(20) id"` + CreatedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated_at TIMESTAMP"` + FromQuestionID string `xorm:"not null default 0 BIGINT(20) index from_question_id"` + FromAnswerID string `xorm:"BIGINT(20) from_answer_id"` + ToQuestionID string `xorm:"not null default 0 BIGINT(20) index to_question_id"` + ToAnswerID string `xorm:"BIGINT(20) to_answer_id"` + Status int `xorm:"not null default 1 INT(11) status"` +} + +func (QuestionLink) TableName() string { + return "question_link" +} diff --git a/internal/migrations/init_data.go b/internal/migrations/init_data.go index 50a5651b..b0143b33 100644 --- a/internal/migrations/init_data.go +++ b/internal/migrations/init_data.go @@ -52,6 +52,7 @@ var ( &entity.Meta{}, &entity.Notification{}, &entity.Question{}, + &entity.QuestionLink{}, &entity.Report{}, &entity.Revision{}, &entity.SiteInfo{}, diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index a32e851a..81a54ae7 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -98,6 +98,7 @@ var migrations = []Migration{ NewMigration("v1.3.0", "add review", addReview, false), NewMigration("v1.3.6", "add hot score to question table", addQuestionHotScore, true), NewMigration("v1.4.0", "add badge/badge_group/badge_award table", addBadges, true), + NewMigration("v1.4.1", "add question link", addQuestionLink, true), } func GetMigrations() []Migration { diff --git a/ui/src/pages/Questions/Detail/components/index.tsx b/internal/migrations/v23.go similarity index 64% copy from ui/src/pages/Questions/Detail/components/index.tsx copy to internal/migrations/v23.go index 1cfe75ee..8b8a3370 100644 --- a/ui/src/pages/Questions/Detail/components/index.tsx +++ b/internal/migrations/v23.go @@ -17,22 +17,15 @@ * under the License. */ -import Question from './Question'; -import Answer from './Answer'; -import AnswerHead from './AnswerHead'; -import RelatedQuestions from './RelatedQuestions'; -import WriteAnswer from './WriteAnswer'; -import Alert from './Alert'; -import ContentLoader from './ContentLoader'; -import InviteToAnswer from './InviteToAnswer'; +package migrations -export { - Question, - Answer, - AnswerHead, - RelatedQuestions, - WriteAnswer, - Alert, - ContentLoader, - InviteToAnswer, -}; +import ( + "context" + + "github.com/apache/incubator-answer/internal/entity" + "xorm.io/xorm" +) + +func addQuestionLink(ctx context.Context, x *xorm.Engine) (err error) { + return x.Context(ctx).Sync(new(entity.QuestionLink)) +} diff --git a/internal/repo/answer/answer_repo.go b/internal/repo/answer/answer_repo.go index 79dac140..f59d6006 100644 --- a/internal/repo/answer/answer_repo.go +++ b/internal/repo/answer/answer_repo.go @@ -270,6 +270,24 @@ func (ar *answerRepo) GetByID(ctx context.Context, answerID string) (*entity.Ans return &resp, has, nil } +func (ar *answerRepo) GetByIDs(ctx context.Context, answerIDs ...string) ([]*entity.Answer, error) { + for idx, answerID := range answerIDs { + answerIDs[idx] = uid.DeShortID(answerID) + } + var resp = make([]*entity.Answer, 0) + err := ar.data.DB.Context(ctx).In("id", answerIDs).Find(&resp) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if handler.GetEnableShortID(ctx) { + for _, item := range resp { + item.ID = uid.EnShortID(item.ID) + item.QuestionID = uid.EnShortID(item.QuestionID) + } + } + return resp, nil +} + func (ar *answerRepo) GetCountByQuestionID(ctx context.Context, questionID string) (int64, error) { questionID = uid.DeShortID(questionID) var resp = new(entity.Answer) diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 2c6e595a..30370281 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -611,3 +611,170 @@ func (qr *questionRepo) RemoveAllUserQuestion(ctx context.Context, userID string } return nil } + +// LinkQuestion batch insert question link +func (qr *questionRepo) LinkQuestion(ctx context.Context, link ...*entity.QuestionLink) (err error) { + // Batch retrieve all links + var links []*entity.QuestionLink + for _, l := range link { + l.FromQuestionID = uid.DeShortID(l.FromQuestionID) + l.ToQuestionID = uid.DeShortID(l.ToQuestionID) + l.FromAnswerID = uid.DeShortID(l.FromAnswerID) + l.ToAnswerID = uid.DeShortID(l.ToAnswerID) + links = append(links, l) + } + // Retrieve existing records from the database + var existLinks []*entity.QuestionLink + session := qr.data.DB.Context(ctx) + for _, link := range links { + session = session.Or(builder.Eq{ + "from_question_id": link.FromQuestionID, + "to_question_id": link.ToQuestionID, + "from_answer_id": link.FromAnswerID, + "to_answer_id": link.ToAnswerID, + }) + } + err = session.Find(&existLinks) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + // Optimize separation of records that need to be updated or inserted using a map + existMap := make(map[string]*entity.QuestionLink) + for _, el := range existLinks { + key := fmt.Sprintf("%s:%s:%s:%s", el.FromQuestionID, el.ToQuestionID, el.FromAnswerID, el.ToAnswerID) + existMap[key] = el + } + + var updateLinks []*entity.QuestionLink + var insertLinks []*entity.QuestionLink + for _, link := range links { + key := fmt.Sprintf("%s:%s:%s:%s", link.FromQuestionID, link.ToQuestionID, link.FromAnswerID, link.ToAnswerID) + if el, exist := existMap[key]; exist { + if el.Status == entity.QuestionLinkStatusDeleted { + el.Status = entity.QuestionLinkStatusAvailable + el.UpdatedAt = time.Now() + updateLinks = append(updateLinks, el) + } + } else { + link.Status = entity.QuestionLinkStatusAvailable + link.CreatedAt = time.Now() + link.UpdatedAt = time.Now() + insertLinks = append(insertLinks, link) + } + } + + // Batch update + if len(updateLinks) > 0 { + for _, link := range updateLinks { + _, err = qr.data.DB.Context(ctx).ID(link.ID).Cols("status").Update(&entity.QuestionLink{Status: entity.QuestionLinkStatusAvailable}) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + } + } + + // Batch insert + if len(insertLinks) > 0 { + _, err = qr.data.DB.Context(ctx).Insert(insertLinks) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + } + + return +} + +// RecoverQuestionLink batch recover question link +func (qr *questionRepo) RecoverQuestionLink(ctx context.Context, links ...*entity.QuestionLink) (err error) { + return qr.UpdateQuestionLinkStatus(ctx, entity.QuestionLinkStatusAvailable, links...) +} + +// RemoveQuestionLink batch remove question link +func (qr *questionRepo) RemoveQuestionLink(ctx context.Context, links ...*entity.QuestionLink) (err error) { + return qr.UpdateQuestionLinkStatus(ctx, entity.QuestionLinkStatusDeleted, links...) +} + +// UpdateQuestionLinkStatus update question link status +func (qr *questionRepo) UpdateQuestionLinkStatus(ctx context.Context, status int, links ...*entity.QuestionLink) (err error) { + if len(links) == 0 { + return nil + } + + session := qr.data.DB.Context(ctx).Cols("status") + for _, link := range links { + eq := builder.Eq{} + if link.FromQuestionID != "" { + eq["from_question_id"] = uid.DeShortID(link.FromQuestionID) + } + if link.FromAnswerID != "" { + eq["from_answer_id"] = uid.DeShortID(link.FromAnswerID) + } + if link.ToQuestionID != "" { + eq["to_question_id"] = uid.DeShortID(link.ToQuestionID) + } + if link.ToAnswerID != "" { + eq["to_answer_id"] = uid.DeShortID(link.ToAnswerID) + } + session = session.Or(eq) + } + _, err = session.Update(&entity.QuestionLink{Status: status}) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// GetQuestionLink get linked question to questionID +func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questionList []*entity.Question, total int64, err error) { + questionList = make([]*entity.Question, 0) + questionID = uid.DeShortID(questionID) + questionStatus := []int{entity.QuestionStatusAvailable, entity.QuestionStatusPending} + if questionID == "0" { + return nil, 0, errors.InternalServer(reason.DatabaseError).WithError( + fmt.Errorf("questionID is empty"), + ).WithStack() + } + + session := qr.data.DB.Context(ctx). + Table("question_link"). + Join("INNER", "question", "question_link.from_question_id = question.id"). + Where("question_link.to_question_id = ? AND question.show = ?", questionID, entity.QuestionShow). + Distinct("question.id"). + Where("question_link.status = ?", entity.QuestionLinkStatusAvailable). + Select("question.*"). + In("question.status", questionStatus) + + switch orderCond { + case "newest": + session.OrderBy("question.pin desc,question.created_at DESC") + case "active": + if inDays == 0 { + session.And("question.created_at > ?", time.Now().AddDate(0, 0, -180)) + } + session.And("question.post_update_time > ?", time.Now().AddDate(0, 0, -90)) + session.OrderBy("question.pin desc,question.post_update_time DESC, question.updated_at DESC") + case "hot": + session.OrderBy("question.pin desc,question.hot_score DESC") + case "score": + session.OrderBy("question.pin desc,question.vote_count DESC, question.view_count DESC") + case "unanswered": + session.Where("question.answer_count = 0") + session.OrderBy("question.pin desc,question.created_at DESC") + } + + if page > 0 && pageSize > 0 { + session.Limit(pageSize, (page-1)*pageSize) + } + + total, err = pager.Help(page, pageSize, &questionList, &entity.Question{}, session) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if handler.GetEnableShortID(ctx) { + for _, item := range questionList { + item.ID = uid.EnShortID(item.ID) + } + } + return +} diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index cca997bd..2927afef 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -169,6 +169,7 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/question/similar/tag", a.questionController.SimilarQuestion) r.GET("/personal/qa/top", a.questionController.UserTop) r.GET("/personal/question/page", a.questionController.PersonalQuestionPage) + r.GET("/question/link", a.questionController.GetQuestionLink) // comment r.GET("/comment/page", a.commentController.GetCommentWithPage) diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index 2a6e5f12..7c605765 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -498,3 +498,17 @@ type PersonalCollectionPageReq struct { PageSize int `validate:"omitempty,min=1" form:"page_size"` UserID string `json:"-"` } + +type GetQuestionLinkReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1,max=100" form:"page_size"` + QuestionID string `validate:"required" form:"question_id"` + OrderCond string `validate:"omitempty,oneof=newest active hot score unanswered recommend" form:"order"` + InDays int `validate:"omitempty,min=1" form:"in_days"` + + LoginUserID string `json:"-"` +} + +type GetQuestionLinkResp struct { + QuestionPageResp +} diff --git a/internal/service/answer_common/answer.go b/internal/service/answer_common/answer.go index 1517b22d..45b11886 100644 --- a/internal/service/answer_common/answer.go +++ b/internal/service/answer_common/answer.go @@ -39,6 +39,7 @@ type AnswerRepo interface { GetAnswerPage(ctx context.Context, page, pageSize int, answer *entity.Answer) (answerList []*entity.Answer, total int64, err error) UpdateAcceptedStatus(ctx context.Context, acceptedAnswerID string, questionID string) error GetByID(ctx context.Context, answerID string) (*entity.Answer, bool, error) + GetByIDs(ctx context.Context, answerIDs ...string) ([]*entity.Answer, error) GetCountByQuestionID(ctx context.Context, questionID string) (int64, error) GetCountByUserID(ctx context.Context, userID string) (int64, error) GetIDsByUserIDAndQuestionID(ctx context.Context, userID string, questionID string) ([]string, error) diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index c34e9081..e8842891 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -22,9 +22,11 @@ package content import ( "context" "encoding/json" - "github.com/apache/incubator-answer/internal/service/event_queue" + "strings" "time" + "github.com/apache/incubator-answer/internal/service/event_queue" + "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/reason" "github.com/apache/incubator-answer/internal/entity" @@ -42,6 +44,7 @@ import ( "github.com/apache/incubator-answer/internal/service/revision_common" "github.com/apache/incubator-answer/internal/service/role" usercommon "github.com/apache/incubator-answer/internal/service/user_common" + "github.com/apache/incubator-answer/pkg/checker" "github.com/apache/incubator-answer/pkg/converter" "github.com/apache/incubator-answer/pkg/htmltext" "github.com/apache/incubator-answer/pkg/token" @@ -166,6 +169,17 @@ func (as *AnswerService) RemoveAnswer(ctx context.Context, req *schema.RemoveAns if err != nil { log.Error("user IncreaseAnswerCount error", err.Error()) } + err = as.questionRepo.RemoveQuestionLink(ctx, &entity.QuestionLink{ + FromQuestionID: answerInfo.QuestionID, + FromAnswerID: answerInfo.ID, + }, &entity.QuestionLink{ + ToQuestionID: answerInfo.QuestionID, + ToAnswerID: answerInfo.ID, + }) + if err != nil { + log.Error("RemoveQuestionLink error", err.Error()) + } + // #2372 In order to simplify the process and complexity, as well as to consider if it is in-house, // facing the problem of recovery. //err = as.answerActivityService.DeleteAnswer(ctx, answerInfo.ID, answerInfo.CreatedAt, answerInfo.VoteCount) @@ -199,6 +213,15 @@ func (as *AnswerService) RecoverAnswer(ctx context.Context, req *schema.RecoverA if err = as.answerRepo.RecoverAnswer(ctx, req.AnswerID); err != nil { return err } + if err = as.questionRepo.RecoverQuestionLink(ctx, &entity.QuestionLink{ + FromQuestionID: answerInfo.QuestionID, + FromAnswerID: answerInfo.ID, + }, &entity.QuestionLink{ + ToQuestionID: answerInfo.QuestionID, + ToAnswerID: answerInfo.ID, + }); err != nil { + return err + } if err = as.questionCommon.UpdateAnswerCount(ctx, answerInfo.QuestionID); err != nil { log.Errorf("update answer count failed: %s", err.Error()) @@ -251,6 +274,15 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) ( if err := as.answerRepo.UpdateAnswerStatus(ctx, insertData.ID, insertData.Status); err != nil { return "", err } + if insertData.Status == entity.AnswerStatusAvailable { + insertData.ParsedText, err = as.updateAnswerLink(ctx, insertData) + if err != nil { + return "", err + } + if err = as.answerRepo.UpdateAnswer(ctx, insertData, []string{"parsed_text"}); err != nil { + return "", err + } + } err = as.questionCommon.UpdateAnswerCount(ctx, req.QuestionID) if err != nil { log.Error("IncreaseAnswerCount error", err.Error()) @@ -366,6 +398,10 @@ func (as *AnswerService) Update(ctx context.Context, req *schema.AnswerUpdateReq if !canUpdate { revisionDTO.Status = entity.RevisionUnreviewedStatus } else { + insertData.ParsedText, err = as.updateAnswerLink(ctx, insertData) + if err != nil { + return "", err + } if err = as.answerRepo.UpdateAnswer(ctx, insertData, []string{"original_text", "parsed_text", "updated_at", "last_edit_user_id"}); err != nil { return "", err } @@ -710,3 +746,79 @@ func (as *AnswerService) notificationAnswerTheQuestion(ctx context.Context, externalNotificationMsg.NewAnswerTemplateRawData = rawData as.externalNotificationQueueService.Send(ctx, externalNotificationMsg) } + +func (as *AnswerService) updateAnswerLink(ctx context.Context, answer *entity.Answer) (string, error) { + err := as.questionRepo.RemoveQuestionLink(ctx, &entity.QuestionLink{ + FromQuestionID: uid.DeShortID(answer.QuestionID), + FromAnswerID: uid.DeShortID(answer.ID), + }) + retParsedText := answer.ParsedText + if err != nil { + return retParsedText, err + } + links := checker.GetQuestionLink(answer.OriginalText) + // validate links + questionLinks := make([]*entity.QuestionLink, 0) + answerCache := make(map[string]string) + questionCache := make(map[string]string) + answerIDList := make([]string, 0) + questionIDList := make([]string, 0) + for _, link := range links { + if link.AnswerID != "" { + answerIDList = append(answerIDList, link.AnswerID) + } + if link.QuestionID != "" { + questionIDList = append(questionIDList, link.QuestionID) + } + } + answerInfoList, err := as.answerRepo.GetByIDs(ctx, answerIDList...) + if err != nil { + return answer.ParsedText, err + } + for _, answer := range answerInfoList { + answerCache[answer.ID] = answer.QuestionID + } + questionInfoList, err := as.questionRepo.FindByID(ctx, questionIDList) + if err != nil { + return answer.ParsedText, err + } + for _, question := range questionInfoList { + questionCache[question.ID] = question.ParsedText + } + + for _, link := range links { + if link.QuestionID != "" { + if _, ok := questionCache[link.QuestionID]; !ok { + continue + } + } + if link.AnswerID != "" { + if _, ok := answerCache[link.AnswerID]; !ok { + continue + } + if link.QuestionID == "" { + link.QuestionID = answerCache[link.AnswerID] + } + } + + questionLinks = append(questionLinks, &entity.QuestionLink{ + FromQuestionID: uid.DeShortID(answer.QuestionID), + FromAnswerID: uid.DeShortID(answer.ID), + ToQuestionID: uid.DeShortID(link.QuestionID), + ToAnswerID: uid.DeShortID(link.AnswerID), + }) + + if link.QuestionID != "" { + retParsedText = strings.Replace(retParsedText, "#"+link.QuestionID, "<a href=\"/questions/"+link.QuestionID+"\">#"+link.QuestionID+"</a>", -1) + } + if link.AnswerID != "" { + questionID := answerCache[link.AnswerID] + retParsedText = strings.Replace(retParsedText, "#"+link.AnswerID, "<a href=\"/questions/"+questionID+"/"+link.AnswerID+"\">#"+link.AnswerID+"</a>", -1) + } + } + if err = as.questionRepo.LinkQuestion(ctx, questionLinks...); err != nil { + return retParsedText, err + } + + return retParsedText, nil +} diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index 9a6dd280..a57d3fac 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -22,10 +22,11 @@ package content import ( "encoding/json" "fmt" - "github.com/apache/incubator-answer/internal/service/event_queue" "strings" "time" + "github.com/apache/incubator-answer/internal/service/event_queue" + "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/handler" "github.com/apache/incubator-answer/internal/base/pager" @@ -347,6 +348,16 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question if err := qs.questionRepo.UpdateQuestionStatus(ctx, question.ID, question.Status); err != nil { return nil, err } + if question.Status == entity.QuestionStatusAvailable { + question.ParsedText, err = qs.updateQuestionLink(ctx, question) + if err != nil { + return nil, err + } + err = qs.questionRepo.UpdateQuestion(ctx, question, []string{"parsed_text"}) + if err != nil { + return nil, err + } + } objectTagData := schema.TagChange{} objectTagData.ObjectID = question.ID objectTagData.Tags = req.Tags @@ -425,6 +436,14 @@ func (qs *QuestionService) OperationQuestion(ctx context.Context, req *schema.Op switch req.Operation { case schema.QuestionOperationHide: questionInfo.Show = entity.QuestionHide + err = qs.questionRepo.RemoveQuestionLink(ctx, &entity.QuestionLink{ + FromQuestionID: questionInfo.ID, + }, &entity.QuestionLink{ + ToQuestionID: questionInfo.ID, + }) + if err != nil { + return + } err = qs.tagCommon.HideTagRelListByObjectID(ctx, req.ID) if err != nil { return err @@ -435,6 +454,14 @@ func (qs *QuestionService) OperationQuestion(ctx context.Context, req *schema.Op } case schema.QuestionOperationShow: questionInfo.Show = entity.QuestionShow + err = qs.questionRepo.RecoverQuestionLink(ctx, &entity.QuestionLink{ + FromQuestionID: questionInfo.ID, + }, &entity.QuestionLink{ + ToQuestionID: questionInfo.ID, + }) + if err != nil { + return + } err = qs.tagCommon.ShowTagRelListByObjectID(ctx, req.ID) if err != nil { return err @@ -553,6 +580,14 @@ func (qs *QuestionService) RemoveQuestion(ctx context.Context, req *schema.Remov // if err != nil { // log.Errorf("user DeleteQuestion rank rollback error %s", err.Error()) // } + err = qs.questionRepo.RemoveQuestionLink(ctx, &entity.QuestionLink{ + FromQuestionID: questionInfo.ID, + }, &entity.QuestionLink{ + ToQuestionID: questionInfo.ID, + }) + if err != nil { + return + } qs.activityQueueService.Send(ctx, &schema.ActivityMsg{ UserID: questionInfo.UserID, TriggerUserID: converter.StringToInt64(req.UserID), @@ -677,6 +712,14 @@ func (qs *QuestionService) RecoverQuestion(ctx context.Context, req *schema.Ques log.Errorf("update tag's question count failed, %v", err) } } + err = qs.questionRepo.RecoverQuestionLink(ctx, &entity.QuestionLink{ + FromQuestionID: questionInfo.ID, + }, &entity.QuestionLink{ + ToQuestionID: questionInfo.ID, + }) + if err != nil { + return + } qs.activityQueueService.Send(ctx, &schema.ActivityMsg{ UserID: req.UserID, @@ -921,6 +964,10 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest //Direct modification revisionDTO.Status = entity.RevisionReviewPassStatus //update question to db + question.ParsedText, err = qs.updateQuestionLink(ctx, question) + if err != nil { + return questionInfo, err + } saveerr := qs.questionRepo.UpdateQuestion(ctx, question, []string{"title", "original_text", "parsed_text", "updated_at", "post_update_time", "last_edit_user_id"}) if saveerr != nil { return questionInfo, saveerr @@ -930,7 +977,7 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest objectTagData.Tags = req.Tags objectTagData.UserID = req.UserID tagerr := qs.ChangeTag(ctx, &objectTagData) - if err != nil { + if tagerr != nil { return questionInfo, tagerr } } @@ -1573,3 +1620,97 @@ func (qs *QuestionService) SitemapCron(ctx context.Context) { ctx = context.WithValue(ctx, constant.ShortIDFlag, siteSeo.IsShortLink()) qs.questioncommon.SitemapCron(ctx) } + +func (qs *QuestionService) updateQuestionLink(ctx context.Context, questionInfo *entity.Question) (string, error) { + err := qs.questionRepo.RemoveQuestionLink(ctx, &entity.QuestionLink{ + FromQuestionID: questionInfo.ID, + FromAnswerID: "0", + }) + if err != nil { + return questionInfo.ParsedText, err + } + retParsedText := questionInfo.ParsedText + links := checker.GetQuestionLink(questionInfo.OriginalText) + // validate links + questionLinks := make([]*entity.QuestionLink, 0) + answerCache := make(map[string]string) + questionCache := make(map[string]string) + answerIDList := make([]string, 0) + questionIDList := make([]string, 0) + for _, link := range links { + if link.AnswerID != "" { + answerIDList = append(answerIDList, link.AnswerID) + } + if link.QuestionID != "" { + questionIDList = append(questionIDList, link.QuestionID) + } + } + answerInfoList, err := qs.answerRepo.GetByIDs(ctx, answerIDList...) + if err != nil { + return questionInfo.ParsedText, err + } + for _, answer := range answerInfoList { + answerCache[answer.ID] = answer.QuestionID + } + questionInfoList, err := qs.questionRepo.FindByID(ctx, questionIDList) + if err != nil { + return questionInfo.ParsedText, err + } + for _, question := range questionInfoList { + questionCache[question.ID] = question.ParsedText + } + + for _, link := range links { + if link.QuestionID != "" { + if _, ok := questionCache[link.QuestionID]; !ok { + continue + } + } + if link.AnswerID != "" { + if _, ok := answerCache[link.AnswerID]; !ok { + continue + } + if link.QuestionID == "" { + link.QuestionID = answerCache[link.AnswerID] + } + } + + questionLinks = append(questionLinks, &entity.QuestionLink{ + FromQuestionID: uid.DeShortID(questionInfo.ID), + FromAnswerID: "0", + ToQuestionID: uid.DeShortID(link.QuestionID), + ToAnswerID: uid.DeShortID(link.AnswerID), + }) + + if link.QuestionID != "" { + retParsedText = strings.Replace(retParsedText, "#"+link.QuestionID, "<a href=\"/questions/"+link.QuestionID+"\">#"+link.QuestionID+"</a>", -1) + } + if link.AnswerID != "" { + questionID := answerCache[link.AnswerID] + retParsedText = strings.Replace(retParsedText, "#"+link.AnswerID, "<a href=\"/questions/"+questionID+"/"+link.AnswerID+"\">#"+link.AnswerID+"</a>", -1) + } + } + if err = qs.questionRepo.LinkQuestion(ctx, questionLinks...); err != nil { + return retParsedText, err + } + + return retParsedText, nil +} + +func (qs *QuestionService) GetQuestionLink(ctx context.Context, req *schema.GetQuestionLinkReq) ( + questions []*schema.QuestionPageResp, total int64, err error) { + if req.OrderCond == schema.QuestionOrderCondHot { + req.InDays = schema.HotInDays + } + + questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, req.Page, req.PageSize, req.QuestionID, req.OrderCond, req.InDays) + if err != nil { + return nil, 0, err + } + + questions, err = qs.questioncommon.FormatQuestionsPage(ctx, questionList, req.LoginUserID, req.OrderCond) + if err != nil { + return nil, 0, err + } + return questions, total, nil +} diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 5dab1214..f242b3f8 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -75,6 +75,11 @@ type QuestionRepo interface { SitemapQuestions(ctx context.Context, page, pageSize int) (questionIDList []*schema.SiteMapQuestionInfo, err error) RemoveAllUserQuestion(ctx context.Context, userID string) (err error) UpdateSearch(ctx context.Context, questionID string) (err error) + LinkQuestion(ctx context.Context, link ...*entity.QuestionLink) (err error) + RemoveQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error) + RecoverQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error) + UpdateQuestionLinkStatus(ctx context.Context, status int, links ...*entity.QuestionLink) (err error) + GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questions []*entity.Question, total int64, err error) } // QuestionCommon user service diff --git a/pkg/checker/question_link.go b/pkg/checker/question_link.go new file mode 100644 index 00000000..5907affa --- /dev/null +++ b/pkg/checker/question_link.go @@ -0,0 +1,107 @@ +package checker + +import ( + "github.com/apache/incubator-answer/internal/base/constant" + "github.com/apache/incubator-answer/pkg/obj" +) + +const ( + QuestionLinkTypeURL = 1 + QuestionLinkTypeID = 2 +) + +type QuestionLink struct { + LinkType int + QuestionID string + AnswerID string +} + +func GetQuestionLink(content string) []QuestionLink { + uniqueIDs := make(map[string]struct{}) + var questionLinks []QuestionLink + + // use two pointer to find the link + left, right := 0, 0 + for right < len(content) { + // find "/questions/" or "#" + if right+11 < len(content) && content[right:right+11] == "/questions/" { + left = right + right += 11 + processURL(content, &left, &right, uniqueIDs, &questionLinks) + } else if content[right] == '#' { + left = right + 1 + right = left + processID(content, &left, &right, uniqueIDs, &questionLinks) + } else { + right++ + } + } + + return questionLinks +} + +func processURL(content string, left, right *int, uniqueIDs map[string]struct{}, questionLinks *[]QuestionLink) { + for *right < len(content) && isDigit(content[*right]) { + *right++ + } + questionID := content[*left+len("/questions/") : *right] + + answerID := "" + if *right < len(content) && content[*right] == '/' { + *left = *right + 1 + *right = *left + for *right < len(content) && isDigit(content[*right]) { + *right++ + } + answerID = content[*left:*right] + } + + addUniqueID(questionID, answerID, QuestionLinkTypeURL, uniqueIDs, questionLinks) +} + +func processID(content string, left, right *int, uniqueIDs map[string]struct{}, questionLinks *[]QuestionLink) { + for *right < len(content) && isDigit(content[*right]) { + *right++ + } + id := content[*left:*right] + addUniqueID(id, "", QuestionLinkTypeID, uniqueIDs, questionLinks) +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} + +func addUniqueID(questionID, answerID string, linkType int, uniqueIDs map[string]struct{}, questionLinks *[]QuestionLink) { + if questionID == "" && answerID == "" { + return + } + + isAdd := false + if answerID != "" { + if objectType, err := obj.GetObjectTypeStrByObjectID(answerID); err == nil && objectType == constant.AnswerObjectType { + if _, ok := uniqueIDs[answerID]; !ok { + uniqueIDs[answerID] = struct{}{} + isAdd = true + } + } + } + + if objectType, err := obj.GetObjectTypeStrByObjectID(questionID); err == nil { + if _, ok := uniqueIDs[questionID]; !ok { + uniqueIDs[questionID] = struct{}{} + isAdd = true + if objectType == constant.AnswerObjectType { + answerID = questionID + questionID = "" + } + } + } + + if isAdd { + *questionLinks = append(*questionLinks, QuestionLink{ + LinkType: linkType, + QuestionID: questionID, + AnswerID: answerID, + }) + } +} diff --git a/pkg/checker/question_link_test.go b/pkg/checker/question_link_test.go new file mode 100644 index 00000000..9d734126 --- /dev/null +++ b/pkg/checker/question_link_test.go @@ -0,0 +1,141 @@ +package checker_test + +import ( + "testing" + + "github.com/apache/incubator-answer/pkg/checker" + "github.com/stretchr/testify/assert" +) + +func TestGetQuestionLink(t *testing.T) { + // Step 1: Test empty content + t.Run("Empty content", func(t *testing.T) { + links := checker.GetQuestionLink("") + assert.Empty(t, links) + }) + + // Step 2: Test content without link or ID + t.Run("Content without link or ID", func(t *testing.T) { + links := checker.GetQuestionLink("This is a random text") + assert.Empty(t, links) + }) + + // Step 3: Test content with valid question link + t.Run("Valid question link", func(t *testing.T) { + links := checker.GetQuestionLink("Check this question: https://example.com/questions/10010000000000060") + assert.Equal(t, []checker.QuestionLink{ + { + LinkType: checker.QuestionLinkTypeURL, + QuestionID: "10010000000000060", + AnswerID: "", + }, + }, links) + }) + + // Step 4: Test content with valid question and answer link + t.Run("Valid question and answer link", func(t *testing.T) { + links := checker.GetQuestionLink("Check this answer: https://example.com/questions/10010000000000060/10020000000000060?from=copy") + assert.Equal(t, []checker.QuestionLink{ + { + LinkType: checker.QuestionLinkTypeURL, + QuestionID: "10010000000000060", + AnswerID: "10020000000000060", + }, + }, links) + }) + + // Step 5: Test content with #questionID + t.Run("Content with #questionID", func(t *testing.T) { + links := checker.GetQuestionLink("This is question #10010000000000060") + assert.Equal(t, []checker.QuestionLink{ + { + LinkType: checker.QuestionLinkTypeID, + QuestionID: "10010000000000060", + AnswerID: "", + }, + }, links) + }) + + // Step 6: Test content with #answerID + t.Run("Content with #answerID", func(t *testing.T) { + links := checker.GetQuestionLink("This is answer #10020000000000060") + assert.Equal(t, []checker.QuestionLink{ + { + LinkType: checker.QuestionLinkTypeID, + QuestionID: "", + AnswerID: "10020000000000060", + }, + }, links) + }) + + // Step 7: Test invalid question ID + t.Run("Invalid question ID", func(t *testing.T) { + links := checker.GetQuestionLink("https://example.com/questions/invalid") + assert.Empty(t, links) + }) + + // Step 8: Test invalid answer ID + t.Run("Invalid answer ID", func(t *testing.T) { + links := checker.GetQuestionLink("https://example.com/questions/10010000000000060/invalid") + assert.Equal(t, []checker.QuestionLink{ + { + LinkType: checker.QuestionLinkTypeURL, + QuestionID: "10010000000000060", + AnswerID: "", + }, + }, links) + }) + + // Step 9: Test content with multiple links and IDs + t.Run("Multiple links and IDs", func(t *testing.T) { + content := "Question #10010000000000060 and https://example.com/questions/10010000000000060/10020000000000061 and https://example.com/questions/10010000000000065/10020000000000066 and another #10020000000000066" + links := checker.GetQuestionLink(content) + assert.Equal(t, []checker.QuestionLink{ + { + LinkType: checker.QuestionLinkTypeID, + QuestionID: "10010000000000060", + AnswerID: "", + }, + { + LinkType: checker.QuestionLinkTypeURL, + QuestionID: "10010000000000060", + AnswerID: "10020000000000061", + }, + { + LinkType: checker.QuestionLinkTypeURL, + QuestionID: "10010000000000065", + AnswerID: "10020000000000066", + }, + }, links) + }) + + // Step 11: Test URL with www prefix + t.Run("URL with www prefix", func(t *testing.T) { + links := checker.GetQuestionLink("Check this question: https://www.example.com/questions/10010000000000060") + assert.Equal(t, []checker.QuestionLink{ + { + LinkType: checker.QuestionLinkTypeURL, + QuestionID: "10010000000000060", + AnswerID: "", + }, + }, links) + }) + + // Step 12: Test URL without protocol + t.Run("URL without protocol", func(t *testing.T) { + links := checker.GetQuestionLink("Check this question: example.com/questions/10010000000000060") + assert.Equal(t, []checker.QuestionLink{ + { + LinkType: checker.QuestionLinkTypeURL, + QuestionID: "10010000000000060", + AnswerID: "", + }, + }, links) + }) + + // Step 14: Test error id + t.Run("Error id", func(t *testing.T) { + links := checker.GetQuestionLink("https://example.com/questions/10110000000000060") + assert.Empty(t, links) + }) +} diff --git a/ui/src/components/QuestionList/index.tsx b/ui/src/components/QuestionList/index.tsx index c41f4b9d..066711bb 100644 --- a/ui/src/components/QuestionList/index.tsx +++ b/ui/src/components/QuestionList/index.tsx @@ -46,7 +46,7 @@ export const QUESTION_ORDER_KEYS: Type.QuestionOrderBy[] = [ 'recommend', ]; interface Props { - source: 'questions' | 'tag'; + source: 'questions' | 'tag' | 'linked'; order?: Type.QuestionOrderBy; data; orderList?: Type.QuestionOrderBy[]; diff --git a/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx b/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx new file mode 100644 index 00000000..a3a7a67a --- /dev/null +++ b/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { memo, FC } from 'react'; +import { Card, ListGroup } from 'react-bootstrap'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { Icon } from '@/components'; +import { useQuestionLink } from '@/services'; +import { pathFactory } from '@/router/pathFactory'; + +interface Props { + id: string; +} +const Index: FC<Props> = ({ id }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'linked_question', + }); + const { t: t2 } = useTranslation('translation', { + keyPrefix: 'related_question', + }); + + const { data, isLoading } = useQuestionLink({ + question_id: id, + page: 1, + page_size: 5, + }); + + if (isLoading) { + return null; + } + + if (!data || !data.list) { + return ( + <Card className="mb-4"> + <Card.Header className="text-nowrap d-flex justify-content-between text-capitalize"> + {t('title')} + <Link to={`/questions/linked/${id}`} className="btn btn-link p-0"> + {t('more', { keyPrefix: 'btns' })} + </Link> + </Card.Header> + <ListGroup variant="flush"> + <div + className="text-muted" + style={{ + padding: 'var(--bs-card-spacer-y) var(--bs-card-spacer-x)', + }}> + {t('no_linked_question')} + </div> + </ListGroup> + </Card> + ); + } + + return ( + <Card className="mb-4"> + <Card.Header className="text-nowrap d-flex justify-content-between text-capitalize"> + {t('title')} + <Link to={`/questions/linked/${id}`} className="btn btn-link p-0"> + {t('more', { keyPrefix: 'btns' })} + </Link> + </Card.Header> + <ListGroup variant="flush"> + {data.list.map((item) => { + return ( + <ListGroup.Item + action + key={item.id} + as={Link} + to={pathFactory.questionLanding(item.id, item.url_title)}> + <div className="link-dark">{item.title}</div> + {item.answer_count > 0 && ( + <div + className={`mt-1 small me-2 ${ + item.accepted_answer_id > 0 + ? 'link-success' + : 'link-secondary' + }`}> + <Icon + name={ + item.accepted_answer_id > 0 + ? 'check-circle-fill' + : 'chat-square-text-fill' + } + className="me-1" + /> + <span> + {item.answer_count} {t2('answers')} + </span> + </div> + )} + </ListGroup.Item> + ); + })} + {data.list.length === 0 ? ( + <div + className="text-muted" + style={{ + padding: 'var(--bs-card-spacer-y) var(--bs-card-spacer-x)', + }}> + {t('no_linked_question')} + </div> + ) : null} + </ListGroup> + </Card> + ); +}; + +export default memo(Index); diff --git a/ui/src/pages/Questions/Detail/components/index.tsx b/ui/src/pages/Questions/Detail/components/index.tsx index 1cfe75ee..ee665790 100644 --- a/ui/src/pages/Questions/Detail/components/index.tsx +++ b/ui/src/pages/Questions/Detail/components/index.tsx @@ -25,6 +25,7 @@ import WriteAnswer from './WriteAnswer'; import Alert from './Alert'; import ContentLoader from './ContentLoader'; import InviteToAnswer from './InviteToAnswer'; +import LinkedQuestions from './LinkedQuestions'; export { Question, @@ -35,4 +36,5 @@ export { Alert, ContentLoader, InviteToAnswer, + LinkedQuestions, }; diff --git a/ui/src/pages/Questions/Detail/index.tsx b/ui/src/pages/Questions/Detail/index.tsx index 0ab02514..8c8921b8 100644 --- a/ui/src/pages/Questions/Detail/index.tsx +++ b/ui/src/pages/Questions/Detail/index.tsx @@ -47,6 +47,7 @@ import { Alert, ContentLoader, InviteToAnswer, + LinkedQuestions, } from './components'; import './index.scss'; @@ -230,6 +231,7 @@ const Index = () => { }); const showInviteToAnswer = question?.id; + const showLinkedQuestions = question?.id && question.id !== ''; let canInvitePeople = false; if (showInviteToAnswer && Array.isArray(question.extends_actions)) { const inviteAct = question.extends_actions.find((op) => { @@ -305,6 +307,7 @@ const Index = () => { readOnly={!canInvitePeople} /> ) : null} + {showLinkedQuestions ? <LinkedQuestions id={question.id} /> : null} <RelatedQuestions id={question?.id || ''} /> </Col> </Row> diff --git a/ui/src/pages/Questions/Linked/index.tsx b/ui/src/pages/Questions/Linked/index.tsx new file mode 100644 index 00000000..14224d77 --- /dev/null +++ b/ui/src/pages/Questions/Linked/index.tsx @@ -0,0 +1,95 @@ +import { FC } from 'react'; +import { Row, Col } from 'react-bootstrap'; +import { useParams, useSearchParams, Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { usePageTags } from '@/hooks'; +import { useQuestionLink } from '@/services'; +import * as Type from '@/common/interface'; +import { + QuestionList, + CustomSidebar, + HotQuestions, + FollowingTags, +} from '@/components'; +import { userCenter, floppyNavigation } from '@/utils'; +import { QUESTION_ORDER_KEYS } from '@/components/QuestionList'; +import { + loggedUserInfoStore, + siteInfoStore, + loginSettingStore, +} from '@/stores'; + +const LinkedQuestions: FC = () => { + const { qid } = useParams<{ qid: string }>(); + const { t } = useTranslation('translation', { keyPrefix: 'linked_question' }); + const { t: t2 } = useTranslation('translation'); + const { user: loggedUser } = loggedUserInfoStore((_) => _); + const [urlSearchParams] = useSearchParams(); + const curPage = Number(urlSearchParams.get('page')) || 1; + const curOrder = (urlSearchParams.get('order') || + QUESTION_ORDER_KEYS[0]) as Type.QuestionOrderBy; + const pageSize = 10; + const { siteInfo } = siteInfoStore(); + const { data: listData, isLoading: listLoading } = useQuestionLink({ + question_id: qid || '', + page: curPage, + page_size: pageSize, + }); + const { login: loginSetting } = loginSettingStore(); + + usePageTags({ + title: t('title'), + }); + + return ( + <Row className="pt-4 mb-5"> + <Col className="page-main flex-auto"> + <QuestionList + source="linked" + data={listData} + order={curOrder} + orderList={ + loggedUser.username + ? QUESTION_ORDER_KEYS + : QUESTION_ORDER_KEYS.filter((key) => key !== 'recommend') + } + isLoading={listLoading} + /> + </Col> + <Col className="page-right-side mt-4 mt-xl-0"> + <CustomSidebar /> + {!loggedUser.username && ( + <div className="card mb-4"> + <div className="card-body"> + <h5 className="card-title"> + {t2('website_welcome', { + site_name: siteInfo.name, + })} + </h5> + <p className="card-text">{siteInfo.description}</p> + <Link + to={userCenter.getLoginUrl()} + className="btn btn-primary" + onClick={floppyNavigation.handleRouteLinkClick}> + {t('login', { keyPrefix: 'btns' })} + </Link> + {loginSetting.allow_new_registrations ? ( + <Link + to={userCenter.getSignUpUrl()} + className="btn btn-link ms-2" + onClick={floppyNavigation.handleRouteLinkClick}> + {t('signup', { keyPrefix: 'btns' })} + </Link> + ) : null} + </div> + </div> + )} + {loggedUser.access_token && <FollowingTags />} + <HotQuestions /> + </Col> + </Row> + ); +}; + +export default LinkedQuestions; diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index aa31266e..b89f312c 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -107,6 +107,10 @@ const routes: RouteNode[] = [ path: 'questions/:qid/:slugPermalink/:aid', page: 'pages/Questions/Detail', }, + { + path: 'questions/linked/:qid', + page: 'pages/Questions/Linked', + }, { path: '/search', page: 'pages/Search', diff --git a/ui/src/services/client/question.ts b/ui/src/services/client/question.ts index b62fbf1a..34d1e75c 100644 --- a/ui/src/services/client/question.ts +++ b/ui/src/services/client/question.ts @@ -119,3 +119,20 @@ export const unDeleteQuestion = (qid) => { question_id: qid, }); }; + +export const useQuestionLink = (params: { + question_id: string; + page: number; + page_size: number; +}) => { + const apiUrl = `/answer/api/v1/question/link?${qs.stringify(params)}`; + const { data, error } = useSWR<Type.ListResult, Error>( + [apiUrl, params], + request.instance.get, + ); + return { + data, + isLoading: !data && !error, + error, + }; +};
