Merge branch 'main' into fix/search

This commit is contained in:
kumfo 2022-10-17 11:11:53 +08:00
commit 164eef862c
53 changed files with 658 additions and 309 deletions

View File

@ -1,24 +1,20 @@
# Answer - Simple Q&A Community <a href="https://answer.dev">
<img alt="logo" src="docs/img/answer-logo-flat.svg" height="63px">
</a>
![logo](docs/img/answer-logo-flat.svg) # Answer - Build Q&A community
[![LICENSE](https://img.shields.io/badge/License-MIT-green)](https://github.com/segmentfault/answer/blob/master/LICENSE) A minimalist open-source knowledge based community software. You can use it to quickly build your Q&A community for product technical support, user Q&A, fans communication, and more.
To learn more about the project, visit [answer.dev](https://answer.dev).
[![LICENSE](https://img.shields.io/badge/License-Apache-green)](https://github.com/answerdev/answer/blob/main/LICENSE)
[![Language](https://img.shields.io/badge/Language-Go-blue.svg)](https://golang.org/) [![Language](https://img.shields.io/badge/Language-Go-blue.svg)](https://golang.org/)
[![Language](https://img.shields.io/badge/Language-React-blue.svg)](https://reactjs.org/) [![Language](https://img.shields.io/badge/Language-React-blue.svg)](https://reactjs.org/)
## What is Answer? ## Screenshots
This is a minimalist open source Q&A community. Users can post questions and others can answer them. ![screenshot](docs/img/screenshot.png)
![abstract](docs/img/abstract.png)
## Why?
- Help organizations build knowledge and Q&A communities better and faster.
## Features
- Produce knowledge by asking and answering questions.
- Maintain knowledge by voting and working together.
## Quick start ## Quick start
@ -40,4 +36,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for ways to get started.
## License ## License
[MIT](https://github.com/segmentfault/answer/blob/master/LICENSE) [Apache](https://github.com/answerdev/answer/blob/main/LICENSE)

View File

@ -1,24 +1,25 @@
![logo](docs/img/answer-logo-flat.svg) <a href="https://answer.dev">
<img alt="logo" src="docs/img/answer-logo-flat.svg" height="63px">
</a>
# Answer - 极简问答社区 # Answer - 构建问答社区
一款极简的、问答形式的知识社区开源软件,用来快速构建产品你的产品问答支持社区、用户问答社区、粉丝社区等。
了解更多关于该项目的内容,请访问 [answer.dev](https://answer.dev).
[![LICENSE](https://img.shields.io/badge/License-MIT-green)](https://github.com/segmentfault/answer/blob/master/LICENSE) [![LICENSE](https://img.shields.io/badge/License-MIT-green)](https://github.com/segmentfault/answer/blob/master/LICENSE)
[![Language](https://img.shields.io/badge/Language-Go-blue.svg)](https://golang.org/) [![Language](https://img.shields.io/badge/Language-Go-blue.svg)](https://golang.org/)
[![Language](https://img.shields.io/badge/Language-React-blue.svg)](https://reactjs.org/) [![Language](https://img.shields.io/badge/Language-React-blue.svg)](https://reactjs.org/)
## 什么是 Answer? ## 截图
这是一个极简的开源问答社区。用户可以发布问题,其他人可以回答。
![abstract](docs/img/abstract.png)
## 目标 ![screenshot](docs/img/screenshot.png)
- 帮助企业更好更快构建知识问答社区
## 产品功能
- 通过提问、回答方式生产知识
- 通过投票、共同协作方式维护知识
## 快速开始 ## 快速开始
### 使用 docker-compose 快速搭建 ### 使用 docker-compose 快速搭建
```bash ```bash
mkdir answer && cd answer mkdir answer && cd answer
wget https://github.com/segmentfault/answer/releases/latest/download/docker-compose.yaml wget https://github.com/segmentfault/answer/releases/latest/download/docker-compose.yaml
@ -31,7 +32,7 @@ docker-compose up
我们随时欢迎你的贡献! 我们随时欢迎你的贡献!
参考 [CONTRIBUTING.md](CONTRIBUTING.md) 其中的贡献指南 参考 [CONTRIBUTING.md](CONTRIBUTING.md) 开始贡献。
## License ## License

View File

@ -3597,12 +3597,12 @@ const docTemplate = `{
"summary": "UserRegisterByEmail", "summary": "UserRegisterByEmail",
"parameters": [ "parameters": [
{ {
"description": "UserRegister", "description": "UserRegisterReq",
"name": "data", "name": "data",
"in": "body", "in": "body",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/definitions/schema.UserRegister" "$ref": "#/definitions/schema.UserRegisterReq"
} }
} }
], ],
@ -5089,14 +5089,14 @@ const docTemplate = `{
"schema.ReportHandleReq": { "schema.ReportHandleReq": {
"type": "object", "type": "object",
"required": [ "required": [
"flaged_type", "flagged_type",
"id" "id"
], ],
"properties": { "properties": {
"flaged_content": { "flagged_content": {
"type": "string" "type": "string"
}, },
"flaged_type": { "flagged_type": {
"type": "integer" "type": "integer"
}, },
"id": { "id": {
@ -5392,6 +5392,11 @@ const docTemplate = `{
"type": "string", "type": "string",
"maxLength": 100 "maxLength": 100
}, },
"username": {
"description": "username",
"type": "string",
"maxLength": 30
},
"website": { "website": {
"description": "website", "description": "website",
"type": "string", "type": "string",
@ -5642,7 +5647,7 @@ const docTemplate = `{
} }
} }
}, },
"schema.UserRegister": { "schema.UserRegisterReq": {
"type": "object", "type": "object",
"required": [ "required": [
"e_mail", "e_mail",
@ -5658,7 +5663,7 @@ const docTemplate = `{
"name": { "name": {
"description": "name", "description": "name",
"type": "string", "type": "string",
"maxLength": 50 "maxLength": 30
}, },
"pass": { "pass": {
"description": "password", "description": "password",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

BIN
docs/img/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -3585,12 +3585,12 @@
"summary": "UserRegisterByEmail", "summary": "UserRegisterByEmail",
"parameters": [ "parameters": [
{ {
"description": "UserRegister", "description": "UserRegisterReq",
"name": "data", "name": "data",
"in": "body", "in": "body",
"required": true, "required": true,
"schema": { "schema": {
"$ref": "#/definitions/schema.UserRegister" "$ref": "#/definitions/schema.UserRegisterReq"
} }
} }
], ],
@ -5077,14 +5077,14 @@
"schema.ReportHandleReq": { "schema.ReportHandleReq": {
"type": "object", "type": "object",
"required": [ "required": [
"flaged_type", "flagged_type",
"id" "id"
], ],
"properties": { "properties": {
"flaged_content": { "flagged_content": {
"type": "string" "type": "string"
}, },
"flaged_type": { "flagged_type": {
"type": "integer" "type": "integer"
}, },
"id": { "id": {
@ -5380,6 +5380,11 @@
"type": "string", "type": "string",
"maxLength": 100 "maxLength": 100
}, },
"username": {
"description": "username",
"type": "string",
"maxLength": 30
},
"website": { "website": {
"description": "website", "description": "website",
"type": "string", "type": "string",
@ -5630,7 +5635,7 @@
} }
} }
}, },
"schema.UserRegister": { "schema.UserRegisterReq": {
"type": "object", "type": "object",
"required": [ "required": [
"e_mail", "e_mail",
@ -5646,7 +5651,7 @@
"name": { "name": {
"description": "name", "description": "name",
"type": "string", "type": "string",
"maxLength": 50 "maxLength": 30
}, },
"pass": { "pass": {
"description": "password", "description": "password",

View File

@ -905,14 +905,14 @@ definitions:
type: object type: object
schema.ReportHandleReq: schema.ReportHandleReq:
properties: properties:
flaged_content: flagged_content:
type: string type: string
flaged_type: flagged_type:
type: integer type: integer
id: id:
type: string type: string
required: required:
- flaged_type - flagged_type
- id - id
type: object type: object
schema.SearchListResp: schema.SearchListResp:
@ -1118,6 +1118,10 @@ definitions:
description: location description: location
maxLength: 100 maxLength: 100
type: string type: string
username:
description: username
maxLength: 30
type: string
website: website:
description: website description: website
maxLength: 500 maxLength: 500
@ -1297,7 +1301,7 @@ definitions:
- code - code
- pass - pass
type: object type: object
schema.UserRegister: schema.UserRegisterReq:
properties: properties:
e_mail: e_mail:
description: email description: email
@ -1305,7 +1309,7 @@ definitions:
type: string type: string
name: name:
description: name description: name
maxLength: 50 maxLength: 30
type: string type: string
pass: pass:
description: password description: password
@ -3535,12 +3539,12 @@ paths:
- application/json - application/json
description: UserRegisterByEmail description: UserRegisterByEmail
parameters: parameters:
- description: UserRegister - description: UserRegisterReq
in: body in: body
name: data name: data
required: true required: true
schema: schema:
$ref: '#/definitions/schema.UserRegister' $ref: '#/definitions/schema.UserRegisterReq'
produces: produces:
- application/json - application/json
responses: responses:

View File

@ -74,8 +74,10 @@ error:
other: "user not found" other: "user not found"
suspended: suspended:
other: "user is suspended" other: "user is suspended"
username_invalid:
other: "username is invalid"
username_duplicate:
other: "username is already in use"
report: report:
spam: spam:

View File

@ -55,7 +55,7 @@ func BindAndCheck(ctx *gin.Context, data interface{}) bool {
errField, err := validator.GetValidatorByLang(lang.Abbr()).Check(data) errField, err := validator.GetValidatorByLang(lang.Abbr()).Check(data)
if err != nil { if err != nil {
HandleResponse(ctx, myErrors.New(http.StatusBadRequest, reason.RequestFormatError).WithMsg(err.Error()), errField) HandleResponse(ctx, err, errField)
return true return true
} }
return false return false

View File

@ -24,6 +24,8 @@ const (
DisallowVoteYourSelf = "error.object.disallow_vote_your_self" DisallowVoteYourSelf = "error.object.disallow_vote_your_self"
CaptchaVerificationFailed = "error.object.captcha_verification_failed" CaptchaVerificationFailed = "error.object.captcha_verification_failed"
UserNotFound = "error.user.not_found" UserNotFound = "error.user.not_found"
UsernameInvalid = "error.user.username_invalid"
UsernameDuplicate = "error.user.username_duplicate"
EmailDuplicate = "error.email.duplicate" EmailDuplicate = "error.email.duplicate"
EmailVerifyUrlExpired = "error.email.verify_url_expired" EmailVerifyUrlExpired = "error.email.verify_url_expired"
EmailNeedToBeVerified = "error.email.need_to_be_verified" EmailNeedToBeVerified = "error.email.need_to_be_verified"

View File

@ -11,7 +11,9 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/go-playground/validator/v10/translations/en" "github.com/go-playground/validator/v10/translations/en"
"github.com/go-playground/validator/v10/translations/zh" "github.com/go-playground/validator/v10/translations/zh"
"github.com/segmentfault/answer/internal/base/reason"
"github.com/segmentfault/answer/internal/base/translator" "github.com/segmentfault/answer/internal/base/translator"
myErrors "github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/i18n" "github.com/segmentfault/pacman/i18n"
) )
@ -98,7 +100,7 @@ func (m *MyValidator) Check(value interface{}) (errField *ErrorField, err error)
Key: translator.GlobalTrans.Tr(m.Lang, fieldError.Field()), Key: translator.GlobalTrans.Tr(m.Lang, fieldError.Field()),
Value: fieldError.Translate(m.Tran), Value: fieldError.Translate(m.Tran),
} }
return errField, errors.New(fieldError.Translate(m.Tran)) return errField, myErrors.BadRequest(reason.RequestFormatError).WithMsg(fieldError.Translate(m.Tran))
} }
} }

View File

@ -206,11 +206,11 @@ func (uc *UserController) UserLogout(ctx *gin.Context) {
// @Tags User // @Tags User
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param data body schema.UserRegister true "UserRegister" // @Param data body schema.UserRegisterReq true "UserRegisterReq"
// @Success 200 {object} handler.RespBody{data=schema.GetUserResp} // @Success 200 {object} handler.RespBody{data=schema.GetUserResp}
// @Router /answer/api/v1/user/register/email [post] // @Router /answer/api/v1/user/register/email [post]
func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) { func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
req := &schema.UserRegister{} req := &schema.UserRegisterReq{}
if handler.BindAndCheck(ctx, req) { if handler.BindAndCheck(ctx, req) {
return return
} }

View File

@ -27,8 +27,8 @@ type Report struct {
ObjectType int `xorm:"not null default 0 comment('revision type') INT(11) object_type"` ObjectType int `xorm:"not null default 0 comment('revision type') INT(11) object_type"`
ReportType int `xorm:"not null default 0 comment('report type') INT(11) report_type"` ReportType int `xorm:"not null default 0 comment('report type') INT(11) report_type"`
Content string `xorm:"not null comment('report content') TEXT content"` Content string `xorm:"not null comment('report content') TEXT content"`
FlagedType int `xorm:"not null default 0 comment('flaged type') INT(11) flaged_type"` FlaggedType int `xorm:"not null default 0 comment('flaged type') INT(11) flaged_type"`
FlagedContent string `xorm:"not null comment('flaged content') TEXT flaged_content"` FlaggedContent string `xorm:"not null comment('flaged content') TEXT flaged_content"`
Status int `xorm:"not null default 1 comment('status(normal: 1; delete 2)') INT(11) status"` Status int `xorm:"not null default 1 comment('status(normal: 1; delete 2)') INT(11) status"`
} }

View File

@ -108,7 +108,7 @@ func (ur *userRepo) UpdateEmail(ctx context.Context, userID, email string) (err
// UpdateInfo update user info // UpdateInfo update user info
func (ur *userRepo) UpdateInfo(ctx context.Context, userInfo *entity.User) (err error) { func (ur *userRepo) UpdateInfo(ctx context.Context, userInfo *entity.User) (err error) {
_, err = ur.data.DB.Where("id = ?", userInfo.ID). _, err = ur.data.DB.Where("id = ?", userInfo.ID).
Cols("display_name", "avatar", "bio", "bio_html", "website", "location").Update(userInfo) Cols("username", "display_name", "avatar", "bio", "bio_html", "website", "location").Update(userInfo)
if err != nil { if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
} }

View File

@ -1,8 +1,9 @@
package schema package schema
import ( import (
"github.com/segmentfault/answer/internal/base/constant"
"time" "time"
"github.com/segmentfault/answer/internal/base/constant"
) )
// AddReportReq add report request // AddReportReq add report request
@ -41,9 +42,9 @@ type GetReportTypeResp struct {
// ReportHandleReq request handle request // ReportHandleReq request handle request
type ReportHandleReq struct { type ReportHandleReq struct {
ID string `validate:"required" comment:"report id" form:"id" json:"id"` ID string `validate:"required" comment:"report id" form:"id" json:"id"`
FlagedType int `validate:"required" comment:"flaged type" form:"flaged_type" json:"flaged_type"` FlaggedType int `validate:"required" comment:"flagged type" form:"flagged_type" json:"flagged_type"`
FlagedContent string `validate:"omitempty" comment:"flaged content" form:"flaged_content" json:"flaged_content"` FlaggedContent string `validate:"omitempty" comment:"flagged content" form:"flagged_content" json:"flagged_content"`
} }
// GetReportListPageDTO report list data transfer object // GetReportListPageDTO report list data transfer object
@ -60,9 +61,9 @@ type GetReportListPageResp struct {
ReportedUser *UserBasicInfo `json:"reported_user"` ReportedUser *UserBasicInfo `json:"reported_user"`
ReportUser *UserBasicInfo `json:"report_user"` ReportUser *UserBasicInfo `json:"report_user"`
Content string `json:"content"` Content string `json:"content"`
FlagedContent string `json:"flaged_content"` FlaggedContent string `json:"flagged_content"`
OType string `json:"object_type"` OType string `json:"object_type"`
ObjectID string `json:"-"` ObjectID string `json:"-"`
QuestionID string `json:"question_id"` QuestionID string `json:"question_id"`
@ -79,15 +80,15 @@ type GetReportListPageResp struct {
UpdatedAt time.Time `json:"_"` UpdatedAt time.Time `json:"_"`
UpdatedAtParsed int64 `json:"updated_at"` UpdatedAtParsed int64 `json:"updated_at"`
Reason *ReasonItem `json:"reason"` Reason *ReasonItem `json:"reason"`
FlagedReason *ReasonItem `json:"flaged_reason"` FlaggedReason *ReasonItem `json:"flagged_reason"`
UserID string `json:"-"` UserID string `json:"-"`
ReportedUserID string `json:"-"` ReportedUserID string `json:"-"`
Status int `json:"-"` Status int `json:"-"`
ObjectType int `json:"-"` ObjectType int `json:"-"`
ReportType int `json:"-"` ReportType int `json:"-"`
FlagedType int `json:"-"` FlaggedType int `json:"-"`
} }
// Format format result // Format format result

View File

@ -2,11 +2,14 @@ package schema
import ( import (
"encoding/json" "encoding/json"
"regexp"
"github.com/jinzhu/copier" "github.com/jinzhu/copier"
"github.com/segmentfault/answer/internal/base/reason"
"github.com/segmentfault/answer/internal/base/validator" "github.com/segmentfault/answer/internal/base/validator"
"github.com/segmentfault/answer/internal/entity" "github.com/segmentfault/answer/internal/entity"
"github.com/segmentfault/answer/pkg/checker" "github.com/segmentfault/answer/pkg/checker"
"github.com/segmentfault/pacman/errors"
) )
// UserVerifyEmailReq user verify email request // UserVerifyEmailReq user verify email request
@ -179,10 +182,10 @@ type UserEmailLogin struct {
CaptchaCode string `json:"captcha_code" ` // captcha_code CaptchaCode string `json:"captcha_code" ` // captcha_code
} }
// Register // UserRegisterReq user register request
type UserRegister struct { type UserRegisterReq struct {
// name // name
Name string `validate:"required,gt=5,lte=50" json:"name"` Name string `validate:"required,gt=4,lte=30" json:"name"`
// email // email
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" `
// password // password
@ -190,7 +193,7 @@ type UserRegister struct {
IP string `json:"-" ` IP string `json:"-" `
} }
func (u *UserRegister) Check() (errField *validator.ErrorField, err error) { func (u *UserRegisterReq) Check() (errField *validator.ErrorField, err error) {
// TODO i18n // TODO i18n
err = checker.PassWordCheck(8, 32, 0, u.Pass) err = checker.PassWordCheck(8, 32, 0, u.Pass)
if err != nil { if err != nil {
@ -224,6 +227,8 @@ func (u *UserModifyPassWordRequest) Check() (errField *validator.ErrorField, err
type UpdateInfoRequest struct { type UpdateInfoRequest struct {
// display_name // display_name
DisplayName string `validate:"required,gt=0,lte=30" json:"display_name"` DisplayName string `validate:"required,gt=0,lte=30" json:"display_name"`
// username
Username string `validate:"omitempty,gt=0,lte=30" json:"username"`
// avatar // avatar
Avatar string `validate:"omitempty,gt=0,lte=500" json:"avatar"` Avatar string `validate:"omitempty,gt=0,lte=500" json:"avatar"`
// bio // bio
@ -238,6 +243,21 @@ type UpdateInfoRequest struct {
UserId string `json:"-" ` UserId string `json:"-" `
} }
func (u *UpdateInfoRequest) Check() (errField *validator.ErrorField, err error) {
if len(u.Username) > 0 {
re := regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
match := re.MatchString(u.Username)
if !match {
err = errors.BadRequest(reason.UsernameInvalid)
return &validator.ErrorField{
Key: "username",
Value: err.Error(),
}, err
}
}
return nil, nil
}
type UserRetrievePassWordRequest struct { type UserRetrievePassWordRequest struct {
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` // e_mail Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` // e_mail
CaptchaID string `json:"captcha_id" ` // captcha_id CaptchaID string `json:"captcha_id" ` // captcha_id

View File

@ -62,10 +62,10 @@ func (rs *ReportBackyardService) ListReportPage(ctx context.Context, dto schema.
flags []entity.Report flags []entity.Report
total int64 total int64
flagedUserIds, flaggedUserIds,
userIds []string userIds []string
flagedUsers, flaggedUsers,
users map[string]*schema.UserBasicInfo users map[string]*schema.UserBasicInfo
) )
@ -78,18 +78,18 @@ func (rs *ReportBackyardService) ListReportPage(ctx context.Context, dto schema.
_ = copier.Copy(&resp, flags) _ = copier.Copy(&resp, flags)
for _, r := range resp { for _, r := range resp {
flagedUserIds = append(flagedUserIds, r.ReportedUserID) flaggedUserIds = append(flaggedUserIds, r.ReportedUserID)
userIds = append(userIds, r.UserID) userIds = append(userIds, r.UserID)
r.Format() r.Format()
} }
// flaged users // flagged users
flagedUsers, err = rs.commonUser.BatchUserBasicInfoByID(ctx, flagedUserIds) flaggedUsers, err = rs.commonUser.BatchUserBasicInfoByID(ctx, flaggedUserIds)
// flag users // flag users
users, err = rs.commonUser.BatchUserBasicInfoByID(ctx, userIds) users, err = rs.commonUser.BatchUserBasicInfoByID(ctx, userIds)
for _, r := range resp { for _, r := range resp {
r.ReportedUser = flagedUsers[r.ReportedUserID] r.ReportedUser = flaggedUsers[r.ReportedUserID]
r.ReportUser = users[r.UserID] r.ReportUser = users[r.UserID]
} }
@ -102,9 +102,9 @@ func (rs *ReportBackyardService) HandleReported(ctx context.Context, req schema.
var ( var (
reported = entity.Report{} reported = entity.Report{}
handleData = entity.Report{ handleData = entity.Report{
FlagedContent: req.FlagedContent, FlaggedContent: req.FlaggedContent,
FlagedType: req.FlagedType, FlaggedType: req.FlaggedType,
Status: entity.ReportStatusCompleted, Status: entity.ReportStatusCompleted,
} }
exist = false exist = false
) )
@ -203,11 +203,11 @@ func (rs *ReportBackyardService) parseObject(ctx context.Context, resp *[]*schem
} }
err = rs.configRepo.GetConfigById(r.ReportType, r.Reason) err = rs.configRepo.GetConfigById(r.ReportType, r.Reason)
} }
if r.FlagedType > 0 { if r.FlaggedType > 0 {
r.FlagedReason = &schema.ReasonItem{ r.FlaggedReason = &schema.ReasonItem{
ReasonType: r.FlagedType, ReasonType: r.FlaggedType,
} }
_ = rs.configRepo.GetConfigById(r.FlagedType, r.FlagedReason) _ = rs.configRepo.GetConfigById(r.FlaggedType, r.FlaggedReason)
} }
res[i] = r res[i] = r

View File

@ -2,6 +2,7 @@ package report_handle_backyard
import ( import (
"context" "context"
"github.com/segmentfault/answer/internal/service/config" "github.com/segmentfault/answer/internal/service/config"
"github.com/segmentfault/answer/internal/base/constant" "github.com/segmentfault/answer/internal/base/constant"
@ -46,23 +47,23 @@ func (rh *ReportHandle) HandleObject(ctx context.Context, reported entity.Report
} }
switch objectKey { switch objectKey {
case "question": case "question":
switch req.FlagedType { switch req.FlaggedType {
case reasonDelete: case reasonDelete:
err = rh.questionCommon.RemoveQuestion(ctx, &schema.RemoveQuestionReq{ID: objectID}) err = rh.questionCommon.RemoveQuestion(ctx, &schema.RemoveQuestionReq{ID: objectID})
case reasonClose: case reasonClose:
err = rh.questionCommon.CloseQuestion(ctx, &schema.CloseQuestionReq{ err = rh.questionCommon.CloseQuestion(ctx, &schema.CloseQuestionReq{
ID: objectID, ID: objectID,
CloseType: req.FlagedType, CloseType: req.FlaggedType,
CloseMsg: req.FlagedContent, CloseMsg: req.FlaggedContent,
}) })
} }
case "answer": case "answer":
switch req.FlagedType { switch req.FlaggedType {
case reasonDelete: case reasonDelete:
err = rh.questionCommon.RemoveAnswer(ctx, objectID) err = rh.questionCommon.RemoveAnswer(ctx, objectID)
} }
case "comment": case "comment":
switch req.FlagedType { switch req.FlaggedType {
case reasonDelete: case reasonDelete:
err = rh.commentRepo.RemoveComment(ctx, objectID) err = rh.commentRepo.RemoveComment(ctx, objectID)
rh.sendNotification(ctx, reportedUserID, objectID, constant.YourCommentWasDeleted) rh.sendNotification(ctx, reportedUserID, objectID, constant.YourCommentWasDeleted)

View File

@ -2,7 +2,9 @@ package service
import ( import (
"context" "context"
"encoding/hex"
"fmt" "fmt"
"math/rand"
"regexp" "regexp"
"strings" "strings"
@ -17,7 +19,6 @@ import (
"github.com/segmentfault/answer/internal/service/service_config" "github.com/segmentfault/answer/internal/service/service_config"
usercommon "github.com/segmentfault/answer/internal/service/user_common" usercommon "github.com/segmentfault/answer/internal/service/user_common"
"github.com/segmentfault/answer/pkg/checker" "github.com/segmentfault/answer/pkg/checker"
"github.com/segmentfault/answer/pkg/uid"
"github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log" "github.com/segmentfault/pacman/log"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -233,18 +234,28 @@ func (us *UserService) UserModifyPassWord(ctx context.Context, request *schema.U
return nil return nil
} }
// UpdateInfo // UpdateInfo update user info
func (us *UserService) UpdateInfo(ctx context.Context, request *schema.UpdateInfoRequest) error { func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoRequest) (err error) {
userinfo := entity.User{} if len(req.Username) > 0 {
userinfo.ID = request.UserId userInfo, exist, err := us.userRepo.GetByUsername(ctx, req.Username)
userinfo.Avatar = request.Avatar if err != nil {
userinfo.DisplayName = request.DisplayName return err
userinfo.Bio = request.Bio }
userinfo.BioHtml = request.BioHtml if exist && userInfo.ID != req.UserId {
userinfo.Location = request.Location return errors.BadRequest(reason.UsernameDuplicate)
userinfo.Website = request.Website }
err := us.userRepo.UpdateInfo(ctx, &userinfo) }
if err != nil {
userInfo := entity.User{}
userInfo.ID = req.UserId
userInfo.Avatar = req.Avatar
userInfo.DisplayName = req.DisplayName
userInfo.Bio = req.Bio
userInfo.BioHtml = req.BioHtml
userInfo.Location = req.Location
userInfo.Website = req.Website
userInfo.Username = req.Username
if err := us.userRepo.UpdateInfo(ctx, &userInfo); err != nil {
return err return err
} }
return nil return nil
@ -259,7 +270,7 @@ func (us *UserService) UserEmailHas(ctx context.Context, email string) (bool, er
} }
// UserRegisterByEmail user register // UserRegisterByEmail user register
func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo *schema.UserRegister) ( func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo *schema.UserRegisterReq) (
resp *schema.GetUserResp, err error) { resp *schema.GetUserResp, err error) {
_, has, err := us.userRepo.GetByEmail(ctx, registerUserInfo.Email) _, has, err := us.userRepo.GetByEmail(ctx, registerUserInfo.Email)
if err != nil { if err != nil {
@ -276,7 +287,7 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
if err != nil { if err != nil {
return nil, err return nil, err
} }
userInfo.Username, err = us.makeUserName(ctx, registerUserInfo.Name) userInfo.Username, err = us.makeUsername(ctx, registerUserInfo.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -408,58 +419,42 @@ func (us *UserService) UserVerifyEmail(ctx context.Context, req *schema.UserVeri
return resp, nil return resp, nil
} }
// makeUserName // makeUsername
// Generate a unique Username based on the NickName // Generate a unique Username based on the displayName
// todo Waiting to be realized func (us *UserService) makeUsername(ctx context.Context, displayName string) (username string, err error) {
func (us *UserService) makeUserName(ctx context.Context, userName string) (string, error) { // Chinese processing
userName = us.formatUserName(ctx, userName) if has := checker.IsChinese(displayName); has {
_, has, err := us.userRepo.GetByUsername(ctx, userName) str, err := pinyin.New(displayName).Split("").Mode(pinyin.WithoutTone).Convert()
if err != nil {
return "", err
}
//If the user name is duplicated, it is generated recursively from the new one.
if has {
userName = uid.IDStr()
return us.makeUserName(ctx, userName)
}
return userName, nil
}
// formatUserName
// Generate a Username through a nickname
func (us *UserService) formatUserName(ctx context.Context, Name string) string {
formatName, pass := us.CheckUserName(ctx, Name)
if !pass {
//todo 重新给用户 生成随机 username
return uid.IDStr()
}
return formatName
}
func (us *UserService) CheckUserName(ctx context.Context, name string) (string, bool) {
name = strings.Replace(name, " ", "_", -1)
name = strings.ToLower(name)
//Chinese processing
has := checker.IsChinese(name)
if has {
str, err := pinyin.New(name).Split("").Mode(pinyin.WithoutTone).Convert()
if err != nil { if err != nil {
log.Error("pinyin Error", err) return "", err
return "", false
} else { } else {
name = str displayName = str
} }
} }
//Format filtering
re, err := regexp.Compile(`^[a-z0-9._-]{4,20}$`) username = strings.ReplaceAll(displayName, " ", "_")
if err != nil { username = strings.ToLower(username)
log.Error("regexp.Compile Error", err, "name", name) suffix := ""
}
match := re.MatchString(name) re := regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
match := re.MatchString(username)
if !match { if !match {
return "", false return "", errors.BadRequest(reason.UsernameInvalid)
} }
return name, true
for {
_, has, err := us.userRepo.GetByUsername(ctx, username+suffix)
if err != nil {
return "", err
}
if !has {
break
}
bytes := make([]byte, 2)
_, _ = rand.Read(bytes)
suffix = hex.EncodeToString(bytes)
}
return username + suffix, nil
} }
// verifyPassword // verifyPassword

View File

View File

@ -195,7 +195,7 @@ export interface PostAnswerReq {
} }
export interface PageUser { export interface PageUser {
id; id?;
displayName; displayName;
userName?; userName?;
avatar_url?; avatar_url?;

View File

@ -3,6 +3,8 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@answer/components'; import { Avatar } from '@answer/components';
import { formatCount } from '@/utils';
interface Props { interface Props {
data: any; data: any;
showAvatar?: boolean; showAvatar?: boolean;
@ -34,7 +36,9 @@ const Index: FC<Props> = ({
</> </>
)} )}
<span className="fw-bold">{data?.rank}</span> <span className="fw-bold" title="Reputation">
{formatCount(data?.rank)}
</span>
</div> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
import { memo } from 'react'; import { memo } from 'react';
import { Button } from 'react-bootstrap'; import { Button, Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -64,6 +64,29 @@ const ActionBar = ({
); );
})} })}
</div> </div>
<Dropdown className="d-block d-md-none">
<Dropdown.Toggle
as="div"
variant="success"
className="no-toggle"
id="dropdown-comment">
<Icon name="three-dots" className="text-secondary" />
</Dropdown.Toggle>
<Dropdown.Menu align="end">
{memberActions.map((action) => {
return (
<Dropdown.Item
key={action.name}
variant="link"
size="sm"
onClick={() => onAction(action)}>
{action.name}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown>
</div> </div>
); );
}; };

View File

@ -29,27 +29,33 @@ const Form = ({
const handleChange = (e) => { const handleChange = (e) => {
setValue(e.target.value); setValue(e.target.value);
}; };
const handleSelected = (val) => {
setValue(val);
};
return ( return (
<div className={classNames('d-flex align-items-start', className)}> <div
className={classNames(
'd-flex align-items-start flex-column flex-md-row',
className,
)}>
<div> <div>
<Mentions pageUsers={pageUsers.getUsers()}> <Mentions pageUsers={pageUsers.getUsers()} onSelected={handleSelected}>
<TextArea size="sm" value={value} onChange={handleChange} /> <TextArea size="sm" value={value} onChange={handleChange} />
</Mentions> </Mentions>
<div className="form-text">{t(`tip_${mode}`)}</div> <div className="form-text">{t(`tip_${mode}`)}</div>
</div> </div>
{type === 'edit' ? ( {type === 'edit' ? (
<div className="d-flex flex-column"> <div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
<Button <Button
size="sm" size="sm"
className="text-nowrap ms-2" className="text-nowrap "
onClick={() => onSendReply(value)}> onClick={() => onSendReply(value)}>
{t('btn_save_edits')} {t('btn_save_edits')}
</Button> </Button>
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
className="text-nowrap ms-2 btn-no-border" className="text-nowrap btn-no-border ms-2 ms-md-0"
onClick={onCancel}> onClick={onCancel}>
{t('btn_cancel')} {t('btn_cancel')}
</Button> </Button>
@ -57,7 +63,7 @@ const Form = ({
) : ( ) : (
<Button <Button
size="sm" size="sm"
className="text-nowrap ms-2" className="text-nowrap ms-0 ms-md-2 mt-2 mt-md-0"
onClick={() => onSendReply(value)}> onClick={() => onSendReply(value)}>
{t('btn_add_comment')} {t('btn_add_comment')}
</Button> </Button>

View File

@ -13,28 +13,33 @@ const Form = ({ userName, onSendReply, onCancel, mode }) => {
const handleChange = (e) => { const handleChange = (e) => {
setValue(e.target.value); setValue(e.target.value);
}; };
const handleSelected = (val) => {
setValue(val);
};
return ( return (
<div className="mb-2"> <div className="mb-2">
<div className="fs-14 mb-2">Reply to {userName}</div> <div className="fs-14 mb-2">Reply to {userName}</div>
<div className="d-flex mb-1 align-items-start"> <div className="d-flex mb-1 align-items-start flex-column flex-md-row">
<div> <div>
<Mentions pageUsers={pageUsers.getUsers()}> <Mentions
pageUsers={pageUsers.getUsers()}
onSelected={handleSelected}>
<TextArea size="sm" value={value} onChange={handleChange} /> <TextArea size="sm" value={value} onChange={handleChange} />
</Mentions> </Mentions>
<div className="form-text">{t(`tip_${mode}`)}</div> <div className="form-text">{t(`tip_${mode}`)}</div>
</div> </div>
<div className="d-flex flex-column"> <div className="d-flex flex-row flex-md-column ms-0 ms-md-2 mt-2 mt-md-0">
<Button <Button
size="sm" size="sm"
className="text-nowrap ms-2" className="text-nowrap"
onClick={() => onSendReply(value)}> onClick={() => onSendReply(value)}>
{t('btn_add_comment')} {t('btn_add_comment')}
</Button> </Button>
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
className="text-nowrap ms-2 btn-no-border" className="text-nowrap btn-no-border ms-2 ms-md-0"
onClick={onCancel}> onClick={onCancel}>
{t('btn_cancel')} {t('btn_cancel')}
</Button> </Button>

View File

@ -1,8 +1,14 @@
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins/_breakpoints';
.comments-wrap { .comments-wrap {
.comment-item { .comment-item {
&:hover { &:hover {
.control-area { @include media-breakpoint-up(md) {
display: flex !important; .control-area {
display: flex !important;
}
} }
} }
} }

View File

@ -0,0 +1,69 @@
import { FC, memo } from 'react';
import { Nav, Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, NavLink } from 'react-router-dom';
import { Avatar, Icon } from '@answer/components';
interface Props {
redDot;
userInfo;
logOut: () => void;
}
const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
const { t } = useTranslation();
return (
<>
<Nav.Link
as={NavLink}
to="/users/notifications/inbox"
className="icon-link d-flex align-items-center justify-content-center p-0 me-3 position-relative">
<div className="text-white text-opacity-75">
<Icon name="bell-fill" className="fs-4" />
</div>
{(redDot?.inbox || 0) > 0 && <div className="unread-dot bg-danger" />}
</Nav.Link>
<Nav.Link
as={Link}
to="/users/notifications/achievement"
className="icon-link d-flex align-items-center justify-content-center p-0 me-3 position-relative">
<div className="text-white text-opacity-75">
<Icon name="trophy-fill" className="fs-4" />
</div>
{(redDot?.achievement || 0) > 0 && (
<div className="unread-dot bg-danger" />
)}
</Nav.Link>
<Dropdown align="end">
<Dropdown.Toggle
variant="success"
id="dropdown-basic"
as="a"
className="no-toggle pointer">
<Avatar size="36px" avatar={userInfo?.avatar} />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item href={`/users/${userInfo.username}`}>
{t('header.nav.profile')}
</Dropdown.Item>
<Dropdown.Item href="/users/settings/profile">
{t('header.nav.setting')}
</Dropdown.Item>
{userInfo?.is_admin ? (
<Dropdown.Item href="/admin">{t('header.nav.admin')}</Dropdown.Item>
) : null}
<Dropdown.Divider />
<Dropdown.Item onClick={logOut}>
{t('header.nav.logout')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
);
};
export default memo(Index);

View File

@ -14,8 +14,8 @@
color: #fff; color: #fff;
} }
&.icon-link { &.icon-link {
width: 46px; width: 36px;
height: 38px; height: 36px;
} }
} }
.placeholder-search { .placeholder-search {
@ -28,4 +28,40 @@
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
} }
} }
.answer-navBar {
font-size: 1rem;
padding: 0.25rem 0.5rem;
border: none;
}
.answer-navBar:focus {
box-shadow: none;
}
.lg-none {
display: none!important;
}
.hr {
color: #fff;
}
} }
@media (max-width: 992.9px) {
#header {
.nav-grow {
flex-grow: 1!important;
}
.lg-none {
display: flex!important;
}
.w-75 {
width: 100% !important;
}
}
}

View File

@ -7,16 +7,22 @@ import {
FormControl, FormControl,
Button, Button,
Col, Col,
Dropdown,
} from 'react-bootstrap'; } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSearchParams, NavLink, Link, useNavigate } from 'react-router-dom'; import {
useSearchParams,
NavLink,
Link,
useNavigate,
useLocation,
} from 'react-router-dom';
import { Avatar, Icon } from '@answer/components';
import { userInfoStore, siteInfoStore, interfaceStore } from '@answer/stores'; import { userInfoStore, siteInfoStore, interfaceStore } from '@answer/stores';
import { logout, useQueryNotificationStatus } from '@answer/api'; import { logout, useQueryNotificationStatus } from '@answer/api';
import Storage from '@answer/utils/storage'; import Storage from '@answer/utils/storage';
import NavItems from './components/NavItems';
import './index.scss'; import './index.scss';
const Header: FC = () => { const Header: FC = () => {
@ -29,6 +35,7 @@ const Header: FC = () => {
const siteInfo = siteInfoStore((state) => state.siteInfo); const siteInfo = siteInfoStore((state) => state.siteInfo);
const { interface: interfaceInfo } = interfaceStore(); const { interface: interfaceInfo } = interfaceStore();
const { data: redDot } = useQueryNotificationStatus(); const { data: redDot } = useQueryNotificationStatus();
const location = useLocation();
const handleInput = (val) => { const handleInput = (val) => {
setSearch(val); setSearch(val);
}; };
@ -45,18 +52,61 @@ const Header: FC = () => {
handleInput(q); handleInput(q);
} }
}, [q]); }, [q]);
useEffect(() => {
const collapse = document.querySelector('#navBarContent');
if (collapse && collapse.classList.contains('show')) {
const toogle = document.querySelector('#navBarToggle') as HTMLElement;
if (toogle) {
toogle?.click();
}
}
}, [location.pathname]);
return ( return (
<Navbar variant="dark" expand="lg" className="sticky-top" id="header"> <Navbar variant="dark" expand="lg" className="sticky-top" id="header">
<Container className="d-flex align-items-center"> <Container className="d-flex align-items-center">
<Navbar.Brand className="lh-1" href="/"> <Navbar.Toggle
{interfaceInfo.logo ? ( aria-controls="navBarContent"
<img className="logo" src={interfaceInfo.logo} alt="" /> className="answer-navBar me-2"
) : ( id="navBarToggle"
<span>{siteInfo.name || 'Answer'}</span> />
)}
</Navbar.Brand> <div className="left-wrap d-flex justify-content-between align-items-center nav-grow">
<Navbar.Toggle aria-controls="navBarContent" /> <Navbar.Brand to="/" as={Link} className="lh-1">
{interfaceInfo.logo ? (
<img
className="logo rounded-1 me-0"
src={interfaceInfo.logo}
alt=""
/>
) : (
<span>{siteInfo.name || 'Answer'}</span>
)}
</Navbar.Brand>
{/* mobile nav */}
<div className="d-flex lg-none align-items-center flex-lg-nowrap">
{user?.username ? (
<NavItems redDot={redDot} userInfo={user} logOut={handleLogout} />
) : (
<>
<Button
variant="link"
className="me-2 text-white"
href="/users/login">
{t('btns.login')}
</Button>
<Button variant="light" href="/users/register">
{t('btns.signup')}
</Button>
</>
)}
</div>
</div>
<Navbar.Collapse id="navBarContent" className="me-auto"> <Navbar.Collapse id="navBarContent" className="me-auto">
<hr className="hr lg-none mb-2" style={{ marginTop: '12px' }} />
<Col md={4}> <Col md={4}>
<Nav> <Nav>
<NavLink className="nav-link" to="/questions"> <NavLink className="nav-link" to="/questions">
@ -70,9 +120,10 @@ const Header: FC = () => {
</NavLink> </NavLink>
</Nav> </Nav>
</Col> </Col>
<hr className="hr lg-none mt-2" />
<Col md={4} className="d-none d-sm-flex justify-content-center"> <Col lg={4} className="d-flex justify-content-center">
<Form action="/search" className="w-75 px-2"> <Form action="/search" className="w-75 px-0 px-lg-2">
<FormControl <FormControl
placeholder={t('header.search.placeholder')} placeholder={t('header.search.placeholder')}
className="text-white placeholder-search" className="text-white placeholder-search"
@ -83,69 +134,32 @@ const Header: FC = () => {
</Form> </Form>
</Col> </Col>
<Nav.Item className="lg-none mt-3 pb-1">
<Link
to="/questions/ask"
className="text-capitalize text-nowrap btn btn-light">
{t('btns.add_question')}
</Link>
</Nav.Item>
{/* pc nav */}
<Col <Col
md={4} lg={4}
className="d-flex justify-content-start justify-content-sm-end"> className="d-none d-lg-flex justify-content-start justify-content-sm-end">
{user?.username ? ( {user?.username ? (
<Nav className="d-flex align-items-center flex-lg-nowrap"> <Nav className="d-flex align-items-center flex-lg-nowrap">
<Nav.Item className="me-2"> <Nav.Item className="me-3">
<Link <Link
to="/questions/ask" to="/questions/ask"
className="text-capitalize text-nowrap btn btn-light"> className="text-capitalize text-nowrap btn btn-light">
{t('btns.add_question')} {t('btns.add_question')}
</Link> </Link>
</Nav.Item> </Nav.Item>
<Nav.Link
as={NavLink}
to="/users/notifications/inbox"
className="icon-link d-flex align-items-center justify-content-center p-0 me-2 position-relative">
<div className="text-white text-opacity-75">
<Icon name="bell-fill" className="fs-5" />
</div>
{(redDot?.inbox || 0) > 0 && (
<div className="unread-dot bg-danger" />
)}
</Nav.Link>
<Nav.Link <NavItems
as={Link} redDot={redDot}
to="/users/notifications/achievement" userInfo={user}
className="icon-link d-flex align-items-center justify-content-center p-0 me-2 position-relative"> logOut={handleLogout}
<div className="text-white text-opacity-75"> />
<Icon name="trophy-fill" className="fs-5" />
</div>
{(redDot?.achievement || 0) > 0 && (
<div className="unread-dot bg-danger" />
)}
</Nav.Link>
<Dropdown align="end">
<Dropdown.Toggle
variant="success"
id="dropdown-basic"
as="a"
className="no-toggle pointer">
<Avatar size="36px" avatar={user?.avatar} />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item href={`/users/${user.username}`}>
{t('header.nav.profile')}
</Dropdown.Item>
<Dropdown.Item href="/users/settings/profile">
{t('header.nav.setting')}
</Dropdown.Item>
{user?.is_admin ? (
<Dropdown.Item href="/admin">
{t('header.nav.admin')}
</Dropdown.Item>
) : null}
<Dropdown.Divider />
<Dropdown.Item onClick={handleLogout}>
{t('header.nav.logout')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Nav> </Nav>
) : ( ) : (
<> <>

View File

@ -6,11 +6,12 @@ import * as Types from '@answer/common/interface';
interface IProps { interface IProps {
children: React.ReactNode; children: React.ReactNode;
pageUsers; pageUsers;
onSelected: (val: string) => void;
} }
const MAX_RECODE = 5; const MAX_RECODE = 5;
const Mentions: FC<IProps> = ({ children, pageUsers }) => { const Mentions: FC<IProps> = ({ children, pageUsers, onSelected }) => {
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const [val, setValue] = useState(''); const [val, setValue] = useState('');
@ -71,23 +72,17 @@ const Mentions: FC<IProps> = ({ children, pageUsers }) => {
if (!selectionStart) { if (!selectionStart) {
return; return;
} }
const str = value.substring(
value.substring(0, selectionStart).lastIndexOf('@'), const text = `@${item?.userName}`;
selectionStart, onSelected(
`${value.substring(
0,
value.substring(0, selectionStart).lastIndexOf('@'),
)}${text}${value.substring(selectionStart)}`,
); );
const text = `@${item?.displayName}[${item?.userName}] `;
element.value = `${value.substring(
0,
value.substring(0, selectionStart).lastIndexOf('@'),
)}${text}${value.substring(selectionStart)}`;
setUsers([]); setUsers([]);
setValue(''); setValue('');
const newSelectionStart = selectionStart + text.length - str.length;
element.setSelectionRange(newSelectionStart, newSelectionStart);
element.focus();
}; };
const filterData = val const filterData = val
? users.filter( ? users.filter(
(item) => (item) =>

View File

@ -1,18 +1,20 @@
import { FC, memo } from 'react'; import { FC, memo } from 'react';
import { ButtonGroup, Button } from 'react-bootstrap'; import { ButtonGroup, Button, DropdownButton, Dropdown } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
interface Props { interface Props {
data: string[] | Array<{ name: string; sort: string }>; data;
i18nkeyPrefix: string; i18nkeyPrefix: string;
currentSort: string; currentSort: string;
sortKey?: string; sortKey?: string;
className?: string; className?: string;
} }
const MAX_BUTTON_COUNT = 3;
const Index: FC<Props> = ({ const Index: FC<Props> = ({
data, data = [],
currentSort = '', currentSort = '',
sortKey = 'order', sortKey = 'order',
i18nkeyPrefix = '', i18nkeyPrefix = '',
@ -37,9 +39,13 @@ const Index: FC<Props> = ({
setUrlSearchParams(str); setUrlSearchParams(str);
}; };
const filteredData = data.filter((_, index) => index > MAX_BUTTON_COUNT - 2);
const currentBtn = filteredData.find((btn) => {
return (typeof btn === 'string' ? btn : btn.name) === currentSort;
});
return ( return (
<ButtonGroup size="sm"> <ButtonGroup size="sm">
{data.map((btn) => { {data.map((btn, index) => {
const key = typeof btn === 'string' ? btn : btn.sort; const key = typeof btn === 'string' ? btn : btn.sort;
const name = typeof btn === 'string' ? btn : btn.name; const name = typeof btn === 'string' ? btn : btn.name;
return ( return (
@ -48,13 +54,55 @@ const Index: FC<Props> = ({
key={key} key={key}
variant="outline-secondary" variant="outline-secondary"
active={currentSort === name} active={currentSort === name}
className={`text-capitalize ${className}`} className={classNames(
'text-capitalize',
data.length > MAX_BUTTON_COUNT &&
index > MAX_BUTTON_COUNT - 2 &&
'd-none d-md-block',
className,
)}
style={
data.length > MAX_BUTTON_COUNT && index === data.length - 1
? {
borderTopRightRadius: '0.25rem',
borderBottomRightRadius: '0.25rem',
}
: {}
}
href={handleParams(key)} href={handleParams(key)}
onClick={(evt) => handleClick(evt, key)}> onClick={(evt) => handleClick(evt, key)}>
{t(name)} {t(name)}
</Button> </Button>
); );
})} })}
{data.length > MAX_BUTTON_COUNT && (
<DropdownButton
size="sm"
variant={currentBtn ? 'secondary' : 'outline-secondary'}
className="d-block d-md-none"
as={ButtonGroup}
title={currentBtn ? t(currentSort) : t('more')}>
{filteredData.map((btn) => {
const key = typeof btn === 'string' ? btn : btn.sort;
const name = typeof btn === 'string' ? btn : btn.name;
return (
<Dropdown.Item
as="a"
key={key}
active={currentSort === name}
className={classNames(
'text-capitalize',
'd-block d-md-none',
className,
)}
href={handleParams(key)}
onClick={(evt) => handleClick(evt, key)}>
{t(name)}
</Dropdown.Item>
);
})}
</DropdownButton>
)}
</ButtonGroup> </ButtonGroup>
); );
}; };

View File

@ -1,26 +1,41 @@
import { memo, FC } from 'react'; import { memo, FC } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import classnames from 'classnames';
import { Avatar, FormatTime } from '@answer/components'; import { Avatar, FormatTime } from '@answer/components';
import { formatCount } from '@/utils';
interface Props { interface Props {
data: any; data: any;
time: number; time: number;
preFix: string; preFix: string;
className?: string;
} }
const Index: FC<Props> = ({ data, time, preFix }) => { const Index: FC<Props> = ({ data, time, preFix, className = '' }) => {
return ( return (
<div className="d-flex"> <div className={classnames('d-flex', className)}>
{data?.status !== 'deleted' ? ( {data?.status !== 'deleted' ? (
<Link to={`/users/${data?.username}`}> <Link to={`/users/${data?.username}`}>
<Avatar avatar={data?.avatar} size="40px" className="me-2" /> <Avatar
avatar={data?.avatar}
size="40px"
className="me-2 d-none d-md-block"
/>
<Avatar
avatar={data?.avatar}
size="24px"
className="me-2 d-block d-md-none"
/>
</Link> </Link>
) : ( ) : (
<Avatar avatar={data?.avatar} size="40px" className="me-2" /> <Avatar avatar={data?.avatar} size="40px" className="me-2" />
)} )}
<div className="fs-14 text-secondary"> <div className="fs-14 text-secondary d-flex flex-row flex-md-column align-items-center align-items-md-start">
<div> <div className="me-1 me-md-0">
{data?.status !== 'deleted' ? ( {data?.status !== 'deleted' ? (
<Link to={`/users/${data?.username}`} className="me-1 text-break"> <Link to={`/users/${data?.username}`} className="me-1 text-break">
{data?.display_name} {data?.display_name}
@ -28,7 +43,9 @@ const Index: FC<Props> = ({ data, time, preFix }) => {
) : ( ) : (
<span className="me-1 text-break">{data?.display_name}</span> <span className="me-1 text-break">{data?.display_name}</span>
)} )}
<span className="fw-bold">{data?.rank}</span> <span className="fw-bold" title="Reputation">
{formatCount(data?.rank)}
</span>
</div> </div>
{time && <FormatTime time={time} preFix={preFix} />} {time && <FormatTime time={time} preFix={preFix} />}
</div> </div>

View File

@ -14,11 +14,11 @@ const usePageUsers = () => {
getUsers, getUsers,
setUsers: (data: Types.PageUser | Types.PageUser[]) => { setUsers: (data: Types.PageUser | Types.PageUser[]) => {
if (data instanceof Array) { if (data instanceof Array) {
setUsers(uniqBy([...users, ...data], 'name')); setUsers(uniqBy([...users, ...data], 'userName'));
globalUsers = uniqBy([...globalUsers, ...data], 'name'); globalUsers = uniqBy([...globalUsers, ...data], 'userName');
} else { } else {
setUsers(uniqBy([...users, data], 'name')); setUsers(uniqBy([...users, data], 'userName'));
globalUsers = uniqBy([...globalUsers, data], 'name'); globalUsers = uniqBy([...globalUsers, data], 'userName');
} }
}, },
}; };

View File

@ -117,9 +117,8 @@ const useReportModal = (callback?: () => void) => {
if (params.isBackend && params.action === 'review') { if (params.isBackend && params.action === 'review') {
putReport({ putReport({
action: params.type, action: params.type,
// FIXME: typo flagged_content: content.value,
flaged_content: content.value, flagged_type: reportType.type,
flaged_type: reportType.type,
id: params.id, id: params.id,
}).then(() => { }).then(() => {
callback?.(); callback?.();

View File

@ -495,6 +495,13 @@
"msg": "Display name cannot be empty.", "msg": "Display name cannot be empty.",
"msg_range": "Display name up to 30 characters" "msg_range": "Display name up to 30 characters"
}, },
"username": {
"label": "Username",
"caption": "People can mention you as @username",
"msg": "Username cannot be empty.",
"msg_range": "Username up to 30 characters",
"character": "Must use the character set \"a-z\", \"0-9\", \" - . _\""
},
"avatar": { "avatar": {
"label": "Profile image", "label": "Profile image",
"text": "You can upload your image or <1>reset</1> it to" "text": "You can upload your image or <1>reset</1> it to"
@ -668,7 +675,8 @@
"answered": "answered", "answered": "answered",
"asked": "asked", "asked": "asked",
"closed": "closed", "closed": "closed",
"follow_a_tag": "Follow a tag" "follow_a_tag": "Follow a tag",
"more": "More"
}, },
"personal": { "personal": {
"overview": "Overview", "overview": "Overview",

View File

@ -53,8 +53,8 @@ a {
height: 18px; height: 18px;
border-radius: 50%; border-radius: 50%;
position: absolute; position: absolute;
left: 22px; left: 20px;
top: 3px; top: 0px;
border: 1px solid #fff; border: 1px solid #fff;
} }

View File

@ -118,8 +118,8 @@ const Flags: FC = () => {
className="fs-14 text-secondary" className="fs-14 text-secondary"
/> />
<BaseUserCard data={li.report_user} className="mt-2 mb-2" /> <BaseUserCard data={li.report_user} className="mt-2 mb-2" />
{li.flaged_reason ? ( {li.flagged_reason ? (
<small>{li.flaged_content}</small> <small>{li.flagged_content}</small>
) : ( ) : (
<small> <small>
{li.reason?.name} {li.reason?.name}

View File

@ -412,7 +412,7 @@ const Ask = () => {
)} )}
</Form> </Form>
</Col> </Col>
<Col xxl={3} lg={4} sm={12}> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<Card className="mb-4"> <Card className="mb-4">
<Card.Header> <Card.Header>
{t('title', { keyPrefix: 'how_to_format' })} {t('title', { keyPrefix: 'how_to_format' })}

View File

@ -102,7 +102,7 @@ const Index: FC<Props> = ({
</div> </div>
<Row className="mt-4 mb-3"> <Row className="mt-4 mb-3">
<Col> <Col className="mb-3 mb-md-0">
<Operate <Operate
qid={data.question_id} qid={data.question_id}
aid={data.id} aid={data.id}
@ -113,7 +113,7 @@ const Index: FC<Props> = ({
callback={callback} callback={callback}
/> />
</Col> </Col>
<Col lg={3}> <Col lg={3} className="mb-3 mb-md-0">
{data.update_user_info?.username !== data.user_info?.username ? ( {data.update_user_info?.username !== data.user_info?.username ? (
<UserCard <UserCard
data={data?.update_user_info} data={data?.update_user_info}

View File

@ -57,7 +57,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
} }
return ( return (
<div> <div>
<h1 className="fs-3 mb-3 text-wrap text-break"> <h1 className="h3 mb-3 text-wrap text-break">
<Link className="link-dark" reloadDocument to={`/questions/${data.id}`}> <Link className="link-dark" reloadDocument to={`/questions/${data.id}`}>
{data.title} {data.title}
{data.status === 2 {data.status === 2
@ -65,7 +65,8 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
: ''} : ''}
</Link> </Link>
</h1> </h1>
<div className="d-flex align-items-center fs-14 mb-2 text-secondary">
<div className="d-flex flex-wrap align-items-center fs-14 mb-3 text-secondary">
<FormatTime <FormatTime
time={data.create_time} time={data.create_time}
preFix={t('Asked')} preFix={t('Asked')}
@ -90,7 +91,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
{followed ? 'Following' : 'Follow'} {followed ? 'Following' : 'Follow'}
</Button> </Button>
</div> </div>
<div className="mb-2 mx-n1"> <div className="m-n1">
{data?.tags?.map((item: any) => { {data?.tags?.map((item: any) => {
return ( return (
<Tag <Tag
@ -105,7 +106,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
<article <article
ref={ref} ref={ref}
dangerouslySetInnerHTML={{ __html: data?.html }} dangerouslySetInnerHTML={{ __html: data?.html }}
className="fmt text-break text-wrap" className="fmt text-break text-wrap mt-4"
/> />
<Actions <Actions
@ -122,7 +123,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
/> />
<Row className="mt-4 mb-3"> <Row className="mt-4 mb-3">
<Col lg={5}> <Col lg={5} className="mb-3 mb-md-0">
<Operate <Operate
qid={data?.id} qid={data?.id}
type="question" type="question"
@ -133,7 +134,7 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer }) => {
callback={initPage} callback={initPage}
/> />
</Col> </Col>
<Col lg={3}> <Col lg={3} className="mb-3 mb-md-0">
{data.update_user_info?.username !== data.user_info?.username ? ( {data.update_user_info?.username !== data.user_info?.username ? (
<UserCard <UserCard
data={data?.user_info} data={data?.user_info}

View File

@ -1,3 +1,11 @@
.answer-item { .answer-item {
border-top: 1px solid rgba(33, 37, 41, 0.25); border-top: 1px solid rgba(33, 37, 41, 0.25);
} }
@media screen and (max-width: 768px) {
.questionDetailPage {
h1.h3 {
font-size: calc(1.275rem + .3vw)!important;
}
}
}

View File

@ -55,7 +55,16 @@ const Index = () => {
} }
res.list.forEach((item) => { res.list.forEach((item) => {
setUsers([item.user_info, item?.update_user_info]); setUsers([
{
displayName: item.user_info.display_name,
userName: item.user_info.username,
},
{
displayName: item?.update_user_info?.display_name,
userName: item?.update_user_info?.username,
},
]);
}); });
} }
}; };
@ -107,9 +116,9 @@ const Index = () => {
return ( return (
<> <>
<PageTitle title={question?.title} /> <PageTitle title={question?.title} />
<Container className="pt-4 mt-2 mb-5"> <Container className="pt-4 mt-2 mb-5 questionDetailPage">
<Row className="justify-content-center"> <Row className="justify-content-center">
<Col xxl={7} lg={8} sm={12}> <Col xxl={7} lg={8} sm={12} className="mb-5 mb-md-0">
{question?.operation?.operation_type && ( {question?.operation?.operation_type && (
<Alert data={question.operation} /> <Alert data={question.operation} />
)} )}
@ -145,6 +154,7 @@ const Index = () => {
/> />
</div> </div>
)} )}
{!question?.operation?.operation_type && ( {!question?.operation?.operation_type && (
<WriteAnswer <WriteAnswer
visible={answers.count === 0} visible={answers.count === 0}
@ -156,7 +166,7 @@ const Index = () => {
/> />
)} )}
</Col> </Col>
<Col xxl={3} lg={4} sm={12}> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<RelatedQuestions id={question?.id || ''} /> <RelatedQuestions id={question?.id || ''} />
</Col> </Col>
</Row> </Row>

View File

@ -217,7 +217,7 @@ const Ask = () => {
</div> </div>
</Form> </Form>
</Col> </Col>
<Col xxl={3} lg={4} sm={12}> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<Card> <Card>
<Card.Header> <Card.Header>
{t('title', { keyPrefix: 'how_to_format' })} {t('title', { keyPrefix: 'how_to_format' })}

View File

@ -29,7 +29,7 @@ const Questions: FC = () => {
<Col xxl={7} lg={8} sm={12}> <Col xxl={7} lg={8} sm={12}>
<QuestionList source="questions" /> <QuestionList source="questions" />
</Col> </Col>
<Col xxl={3} lg={4} sm={12}> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<FollowingTags /> <FollowingTags />
<HotQuestions /> <HotQuestions />
</Col> </Col>

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { QueryGroup } from '@answer/components'; import { QueryGroup } from '@answer/components';
const sortBtns = ['newest', 'active', 'score']; const sortBtns = ['relevance', 'newest', 'active', 'score'];
interface Props { interface Props {
count: number; count: number;

View File

@ -13,7 +13,7 @@ const Index = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const page = searchParams.get('page') || 1; const page = searchParams.get('page') || 1;
const q = searchParams.get('q') || ''; const q = searchParams.get('q') || '';
const order = searchParams.get('order') || 'newest'; const order = searchParams.get('order') || 'relevance';
const { data, isLoading } = useSearch({ const { data, isLoading } = useSearch({
q, q,
@ -53,7 +53,7 @@ const Index = () => {
/> />
</div> </div>
</Col> </Col>
<Col xxl={3} lg={4} sm={12}> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<Tips /> <Tips />
</Col> </Col>
</Row> </Row>

View File

@ -90,7 +90,7 @@ const Questions: FC = () => {
</div> </div>
<QuestionList source="tag" /> <QuestionList source="tag" />
</Col> </Col>
<Col xxl={3} lg={4} sm={12}> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<FollowingTags /> <FollowingTags />
<HotQuestions /> <HotQuestions />
</Col> </Col>

View File

@ -249,7 +249,7 @@ const Ask = () => {
</div> </div>
</Form> </Form>
</Col> </Col>
<Col xxl={3} lg={4} sm={12}> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<Card> <Card>
<Card.Header> <Card.Header>
{t('title', { keyPrefix: 'how_to_format' })} {t('title', { keyPrefix: 'how_to_format' })}

View File

@ -93,7 +93,7 @@ const TagIntroduction = () => {
<PageTitle title={pageTitle} /> <PageTitle title={pageTitle} />
<Container className="pt-4 mt-2 mb-5"> <Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center"> <Row className="justify-content-center">
<Col xs={7}> <Col xxl={7} lg={8} sm={12}>
<h3 className="mb-3"> <h3 className="mb-3">
<Link <Link
to={`/tags/${tagInfo?.slug_name}`} to={`/tags/${tagInfo?.slug_name}`}
@ -133,7 +133,7 @@ const TagIntroduction = () => {
})} })}
</div> </div>
</Col> </Col>
<Col xs={3}> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<Card> <Card>
<Card.Header className="d-flex justify-content-between"> <Card.Header className="d-flex justify-content-between">
<span>{t('synonyms.title')}</span> <span>{t('synonyms.title')}</span>

View File

@ -120,7 +120,7 @@ const Notifications = () => {
</div> </div>
)} )}
</Col> </Col>
<Col xxl={3} lg={4} sm={12} /> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0" />
</Row> </Row>
</Container> </Container>
</> </>

View File

@ -60,7 +60,11 @@ const Personal: FC = () => {
<Col xxl={7} lg={8} sm={12}> <Col xxl={7} lg={8} sm={12}>
<UserInfo data={userInfo?.info} /> <UserInfo data={userInfo?.info} />
</Col> </Col>
<Col xxl={3} lg={4} sm={12} className="d-flex justify-content-end"> <Col
xxl={3}
lg={4}
sm={12}
className="d-flex justify-content-end mt-5 mt-lg-0">
{isSelf && ( {isSelf && (
<div> <div>
<Button <Button
@ -111,7 +115,7 @@ const Personal: FC = () => {
</div> </div>
)} )}
</Col> </Col>
<Col xxl={3} lg={4} sm={12}> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<h5 className="mb-3">Stats</h5> <h5 className="mb-3">Stats</h5>
{userInfo?.info && ( {userInfo?.info && (
<> <>

View File

@ -22,6 +22,11 @@ const Index: React.FC = () => {
isInvalid: false, isInvalid: false,
errorMsg: '', errorMsg: '',
}, },
username: {
value: '',
isInvalid: false,
errorMsg: '',
},
avatar: { avatar: {
value: '', value: '',
isInvalid: false, isInvalid: false,
@ -66,7 +71,7 @@ const Index: React.FC = () => {
const checkValidated = (): boolean => { const checkValidated = (): boolean => {
let bol = true; let bol = true;
const { display_name, website } = formData; const { display_name, website, username } = formData;
if (!display_name.value) { if (!display_name.value) {
bol = false; bol = false;
formData.display_name = { formData.display_name = {
@ -83,6 +88,29 @@ const Index: React.FC = () => {
}; };
} }
if (!username.value) {
bol = false;
formData.username = {
value: '',
isInvalid: true,
errorMsg: t('username.msg'),
};
} else if ([...username.value].length > 30) {
bol = false;
formData.username = {
value: username.value,
isInvalid: true,
errorMsg: t('username.msg_range'),
};
} else if (/[^a-z0-9\-._]/.test(username.value)) {
bol = false;
formData.username = {
value: username.value,
isInvalid: true,
errorMsg: t('username.character'),
};
}
const reg = /^(http|https):\/\//g; const reg = /^(http|https):\/\//g;
if (website.value && !website.value.match(reg)) { if (website.value && !website.value.match(reg)) {
bol = false; bol = false;
@ -101,12 +129,13 @@ const Index: React.FC = () => {
const handleSubmit = (event: FormEvent) => { const handleSubmit = (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (checkValidated() === false) { if (!checkValidated()) {
return; return;
} }
const params = { const params = {
display_name: formData.display_name.value, display_name: formData.display_name.value,
username: formData.username.value,
avatar: formData.avatar.value, avatar: formData.avatar.value,
bio: formData.bio.value, bio: formData.bio.value,
website: formData.website.value, website: formData.website.value,
@ -137,6 +166,7 @@ const Index: React.FC = () => {
const getProfile = () => { const getProfile = () => {
getUserInfo().then((res) => { getUserInfo().then((res) => {
formData.display_name.value = res.display_name; formData.display_name.value = res.display_name;
formData.username.value = res.username;
formData.bio.value = res.bio; formData.bio.value = res.bio;
formData.avatar.value = res.avatar; formData.avatar.value = res.avatar;
formData.location.value = res.location; formData.location.value = res.location;
@ -172,6 +202,29 @@ const Index: React.FC = () => {
</Form.Control.Feedback> </Form.Control.Feedback>
</Form.Group> </Form.Group>
<Form.Group controlId="userName" className="mb-3">
<Form.Label>{t('username.label')}</Form.Label>
<Form.Control
required
type="text"
value={formData.username.value}
isInvalid={formData.username.isInvalid}
onChange={(e) =>
handleChange({
username: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Text as="div">{t('username.caption')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.username.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>{t('avatar.label')}</Form.Label> <Form.Label>{t('avatar.label')}</Form.Label>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">

View File

@ -9,9 +9,18 @@ function getQueryString(name: string): string {
return ''; return '';
} }
function thousandthDivision(num) {
const reg = /\d{1,3}(?=(\d{3})+$)/g;
return `${num}`.replace(reg, '$&,');
}
function formatCount($num: number): string { function formatCount($num: number): string {
let res = String($num); let res = String($num);
if ($num >= 1000 && $num < 1000000) { if (!Number.isFinite($num)) {
res = '0';
} else if ($num < 10000) {
res = thousandthDivision($num);
} else if ($num < 1000000) {
res = `${Math.round($num / 100) / 10}k`; res = `${Math.round($num / 100) / 10}k`;
} else if ($num >= 1000000) { } else if ($num >= 1000000) {
res = `${Math.round($num / 100000) / 10}m`; res = `${Math.round($num / 100000) / 10}m`;
@ -69,8 +78,8 @@ function scrollTop(element) {
* @returns Array<{displayName: string, userName: string}> * @returns Array<{displayName: string, userName: string}>
*/ */
function matchedUsers(markdown) { function matchedUsers(markdown) {
const globalReg = /@(.*?)\[(.*?)\]/gm; const globalReg = /\B@([\w|]+)/g;
const reg = /@(.*?)\[(.*?)\]/; const reg = /\B@([\w\\_\\.]+)/;
const users = markdown.match(globalReg); const users = markdown.match(globalReg);
if (!users) { if (!users) {
@ -79,8 +88,7 @@ function matchedUsers(markdown) {
return users.map((user) => { return users.map((user) => {
const matched = user.match(reg); const matched = user.match(reg);
return { return {
displayName: matched[2], userName: matched[1],
userName: matched[2],
}; };
}); });
} }
@ -91,12 +99,13 @@ function matchedUsers(markdown) {
* @returns string * @returns string
*/ */
function parseUserInfo(markdown) { function parseUserInfo(markdown) {
const globalReg = /@(.*?)\[(.*?)\]/gm; const globalReg = /\B@([\w\\_\\.\\-]+)/g;
return markdown.replace(globalReg, '[@$1](/u/$2)'); return markdown.replace(globalReg, '[@$1](/u/$1)');
} }
export { export {
getQueryString, getQueryString,
thousandthDivision,
formatCount, formatCount,
isLogin, isLogin,
scrollTop, scrollTop,