Merge branch 'test' into 'ai_0.3_dashboard'

# Conflicts:
#   cmd/answer/wire_gen.go
This commit is contained in:
linkinstar 2022-11-03 03:14:29 +00:00
commit cd8b3103d0
162 changed files with 2941 additions and 734 deletions

3
.gitignore vendored
View File

@ -21,4 +21,5 @@ Thumbs*.db
tmp
vendor/
.husky
answer-data/
/answer-data/
/answer

View File

@ -38,9 +38,7 @@ stages:
stage: push
extends: .docker-build-push
only:
- dev
- master
- main
- test
variables:
DockerNamespace: sf_app
DockerImage: answer
@ -52,7 +50,7 @@ stages:
stage: deploy-dev
extends: .deploy-helm
only:
- main
- test
variables:
LoadBalancerIP: 10.0.10.98
KubernetesCluster: dev

View File

@ -13,6 +13,13 @@ GO=$(GO_ENV) $(shell which go)
build:
@$(GO_ENV) $(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC)
# https://dev.to/thewraven/universal-macos-binaries-with-go-1-16-3mm3
universal:
@GOOS=darwin GOARCH=amd64 $(GO_ENV) $(GO) build $(GO_FLAGS) -o ${BIN}_amd64 $(DIR_SRC)
@GOOS=darwin GOARCH=arm64 $(GO_ENV) $(GO) build $(GO_FLAGS) -o ${BIN}_arm64 $(DIR_SRC)
@lipo -create -output ${BIN} ${BIN}_amd64 ${BIN}_arm64
@rm -f ${BIN}_amd64 ${BIN}_arm64
generate:
go get github.com/google/wire/cmd/wire@latest
go generate ./...
@ -23,6 +30,7 @@ test:
# clean all build result
clean:
@$(GO) clean ./...
@rm -f $(BIN)

View File

@ -59,6 +59,8 @@ To run answer, use:
Short: "init answer application",
Long: `init answer application`,
Run: func(_ *cobra.Command, _ []string) {
// installwebapi := server.NewInstallHTTPServer()
// installwebapi.Run(":8088")
cli.InstallAllInitialEnvironment(dataDirPath)
c, err := readConfig()
if err != nil {

View File

@ -79,7 +79,6 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
if err != nil {
return nil, nil, err
}
langController := controller.NewLangController(i18nTranslator)
engine, err := data.NewDB(debug, dbConf)
if err != nil {
return nil, nil, err
@ -93,17 +92,19 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
cleanup()
return nil, nil, err
}
siteInfoRepo := site_info.NewSiteInfo(dataData)
configRepo := config.NewConfigRepo(dataData)
emailRepo := export.NewEmailRepo(dataData)
emailService := export2.NewEmailService(configRepo, emailRepo, siteInfoRepo)
siteInfoService := service.NewSiteInfoService(siteInfoRepo, emailService)
langController := controller.NewLangController(i18nTranslator, siteInfoService)
authRepo := auth.NewAuthRepo(dataData)
authService := auth2.NewAuthService(authRepo)
configRepo := config.NewConfigRepo(dataData)
userRepo := user.NewUserRepo(dataData, configRepo)
uniqueIDRepo := unique.NewUniqueIDRepo(dataData)
activityRepo := activity_common.NewActivityRepo(dataData, uniqueIDRepo, configRepo)
userRankRepo := rank.NewUserRankRepo(dataData, configRepo)
userActiveActivityRepo := activity.NewUserActiveActivityRepo(dataData, activityRepo, userRankRepo, configRepo)
emailRepo := export.NewEmailRepo(dataData)
siteInfoRepo := site_info.NewSiteInfo(dataData)
emailService := export2.NewEmailService(configRepo, emailRepo, siteInfoRepo)
userService := service.NewUserService(userRepo, userActiveActivityRepo, emailService, authService, serviceConf)
captchaRepo := captcha.NewCaptchaRepo(dataData)
captchaService := action.NewCaptchaService(captchaRepo)

View File

@ -62,6 +62,12 @@ const docTemplate = `{
"description": "answer id or question title",
"name": "query",
"in": "query"
},
{
"type": "string",
"description": "question id",
"name": "question_id",
"in": "query"
}
],
"responses": {
@ -143,11 +149,6 @@ const docTemplate = `{
},
"/answer/admin/api/language/options": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Get language options",
"produces": [
"application/json"
@ -1443,11 +1444,6 @@ const docTemplate = `{
},
"/answer/api/v1/language/options": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Get language options",
"produces": [
"application/json"
@ -3402,6 +3398,52 @@ const docTemplate = `{
}
}
},
"/answer/api/v1/user/interface": {
"put": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "UserUpdateInterface update user interface config",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "UserUpdateInterface update user interface config",
"parameters": [
{
"type": "string",
"description": "access-token",
"name": "Authorization",
"in": "header",
"required": true
},
{
"description": "UpdateInfoRequest",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.UpdateUserInterfaceRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/user/login/email": {
"post": {
"description": "UserEmailLogin",
@ -4833,6 +4875,10 @@ const docTemplate = `{
"description": "is admin",
"type": "boolean"
},
"language": {
"description": "language",
"type": "string"
},
"last_login_date": {
"description": "last login date",
"type": "integer"
@ -4929,6 +4975,10 @@ const docTemplate = `{
"description": "is admin",
"type": "boolean"
},
"language": {
"description": "language",
"type": "string"
},
"last_login_date": {
"description": "last login date",
"type": "integer"
@ -5594,6 +5644,19 @@ const docTemplate = `{
}
}
},
"schema.UpdateUserInterfaceRequest": {
"type": "object",
"required": [
"language"
],
"properties": {
"language": {
"description": "language",
"type": "string",
"maxLength": 100
}
}
},
"schema.UpdateUserStatusReq": {
"type": "object",
"required": [

View File

@ -50,6 +50,12 @@
"description": "answer id or question title",
"name": "query",
"in": "query"
},
{
"type": "string",
"description": "question id",
"name": "question_id",
"in": "query"
}
],
"responses": {
@ -131,11 +137,6 @@
},
"/answer/admin/api/language/options": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Get language options",
"produces": [
"application/json"
@ -1431,11 +1432,6 @@
},
"/answer/api/v1/language/options": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Get language options",
"produces": [
"application/json"
@ -3390,6 +3386,52 @@
}
}
},
"/answer/api/v1/user/interface": {
"put": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "UserUpdateInterface update user interface config",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "UserUpdateInterface update user interface config",
"parameters": [
{
"type": "string",
"description": "access-token",
"name": "Authorization",
"in": "header",
"required": true
},
{
"description": "UpdateInfoRequest",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.UpdateUserInterfaceRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/user/login/email": {
"post": {
"description": "UserEmailLogin",
@ -4821,6 +4863,10 @@
"description": "is admin",
"type": "boolean"
},
"language": {
"description": "language",
"type": "string"
},
"last_login_date": {
"description": "last login date",
"type": "integer"
@ -4917,6 +4963,10 @@
"description": "is admin",
"type": "boolean"
},
"language": {
"description": "language",
"type": "string"
},
"last_login_date": {
"description": "last login date",
"type": "integer"
@ -5582,6 +5632,19 @@
}
}
},
"schema.UpdateUserInterfaceRequest": {
"type": "object",
"required": [
"language"
],
"properties": {
"language": {
"description": "language",
"type": "string",
"maxLength": 100
}
}
},
"schema.UpdateUserStatusReq": {
"type": "object",
"required": [

View File

@ -652,6 +652,9 @@ definitions:
is_admin:
description: is admin
type: boolean
language:
description: language
type: string
last_login_date:
description: last login date
type: integer
@ -723,6 +726,9 @@ definitions:
is_admin:
description: is admin
type: boolean
language:
description: language
type: string
last_login_date:
description: last login date
type: integer
@ -1203,6 +1209,15 @@ definitions:
- synonym_tag_list
- tag_id
type: object
schema.UpdateUserInterfaceRequest:
properties:
language:
description: language
maxLength: 100
type: string
required:
- language
type: object
schema.UpdateUserStatusReq:
properties:
status:
@ -1402,6 +1417,10 @@ paths:
in: query
name: query
type: string
- description: question id
in: query
name: question_id
type: string
produces:
- application/json
responses:
@ -1465,8 +1484,6 @@ paths:
description: OK
schema:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: Get language options
tags:
- Lang
@ -2251,8 +2268,6 @@ paths:
description: OK
schema:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: Get language options
tags:
- Lang
@ -3439,6 +3454,35 @@ paths:
summary: UserUpdateInfo update user info
tags:
- User
/answer/api/v1/user/interface:
put:
consumes:
- application/json
description: UserUpdateInterface update user interface config
parameters:
- description: access-token
in: header
name: Authorization
required: true
type: string
- description: UpdateInfoRequest
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.UpdateUserInterfaceRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: UserUpdateInterface update user interface config
tags:
- User
/answer/api/v1/user/login/email:
post:
consumes:

6
i18n/i18n.yaml Normal file
View File

@ -0,0 +1,6 @@
# all support language
language_options:
- label: "简体中文(CN)"
value: "zh_CN"
- label: "English(US)"
value: "en_US"

View File

@ -0,0 +1,59 @@
package server
import (
"embed"
"fmt"
"io/fs"
"net/http"
"github.com/answerdev/answer/ui"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
)
const UIStaticPath = "build/static"
type _resource struct {
fs embed.FS
}
// Open to implement the interface by http.FS required
func (r *_resource) Open(name string) (fs.File, error) {
name = fmt.Sprintf(UIStaticPath+"/%s", name)
log.Debugf("open static path %s", name)
return r.fs.Open(name)
}
// NewHTTPServer new http server.
func NewInstallHTTPServer() *gin.Engine {
r := gin.New()
gin.SetMode(gin.DebugMode)
r.GET("/healthz", func(ctx *gin.Context) { ctx.String(200, "OK??") })
// gin.SetMode(gin.ReleaseMode)
r.StaticFS("/static", http.FS(&_resource{
fs: ui.Build,
}))
installApi := r.Group("")
installApi.GET("/install", Install)
return r
}
func Install(c *gin.Context) {
filePath := ""
var file []byte
var err error
filePath = "build/index.html"
c.Header("content-type", "text/html;charset=utf-8")
file, err = ui.Build.ReadFile(filePath)
if err != nil {
log.Error(err)
c.Status(http.StatusNotFound)
return
}
c.String(http.StatusOK, string(file))
}

View File

@ -1,17 +1,58 @@
package translator
import (
"fmt"
"os"
"path/filepath"
"github.com/google/wire"
myTran "github.com/segmentfault/pacman/contrib/i18n"
"github.com/segmentfault/pacman/i18n"
"sigs.k8s.io/yaml"
)
// ProviderSet is providers.
var ProviderSet = wire.NewSet(NewTranslator)
var GlobalTrans i18n.Translator
// LangOption language option
type LangOption struct {
Label string `json:"label"`
Value string `json:"value"`
}
// LanguageOptions language
var LanguageOptions []*LangOption
// NewTranslator new a translator
func NewTranslator(c *I18n) (tr i18n.Translator, err error) {
GlobalTrans, err = myTran.NewTranslator(c.BundleDir)
if err != nil {
return nil, err
}
i18nFile, err := os.ReadFile(filepath.Join(c.BundleDir, "i18n.yaml"))
if err != nil {
return nil, fmt.Errorf("read i18n file failed: %s", err)
}
s := struct {
LangOption []*LangOption `json:"language_options"`
}{}
err = yaml.Unmarshal(i18nFile, &s)
if err != nil {
return nil, fmt.Errorf("i18n file parsing failed: %s", err)
}
LanguageOptions = s.LangOption
return GlobalTrans, err
}
// CheckLanguageIsValid check user input language is valid
func CheckLanguageIsValid(lang string) bool {
for _, option := range LanguageOptions {
if option.Value == lang {
return true
}
}
return false
}

View File

@ -4,18 +4,20 @@ import (
"encoding/json"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/service"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/i18n"
)
type LangController struct {
translator i18n.Translator
translator i18n.Translator
siteInfoService *service.SiteInfoService
}
// NewLangController new language controller.
func NewLangController(tr i18n.Translator) *LangController {
return &LangController{translator: tr}
func NewLangController(tr i18n.Translator, siteInfoService *service.SiteInfoService) *LangController {
return &LangController{translator: tr, siteInfoService: siteInfoService}
}
// GetLangMapping get language config mapping
@ -33,15 +35,38 @@ func (u *LangController) GetLangMapping(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, resp)
}
// GetLangOptions Get language options
// GetAdminLangOptions Get language options
// @Summary Get language options
// @Description Get language options
// @Security ApiKeyAuth
// @Tags Lang
// @Produce json
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/language/options [get]
// @Router /answer/admin/api/language/options [get]
func (u *LangController) GetLangOptions(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, schema.GetLangOptions)
func (u *LangController) GetAdminLangOptions(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, translator.LanguageOptions)
}
// GetUserLangOptions Get language options
// @Summary Get language options
// @Description Get language options
// @Tags Lang
// @Produce json
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/language/options [get]
func (u *LangController) GetUserLangOptions(ctx *gin.Context) {
siteInterfaceResp, err := u.siteInfoService.GetSiteInterface(ctx)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
options := translator.LanguageOptions
if len(siteInterfaceResp.Language) > 0 {
defaultOption := []*translator.LangOption{
{Label: "Default", Value: siteInterfaceResp.Language},
}
options = append(defaultOption, options...)
}
handler.HandleResponse(ctx, nil, options)
}

View File

@ -391,6 +391,7 @@ func (qc *QuestionController) CmsSearchList(ctx *gin.Context) {
// @Param page_size query int false "page size"
// @Param status query string false "user status" Enums(available,deleted)
// @Param query query string false "answer id or question title"
// @Param question_id query string false "question id"
// @Success 200 {object} handler.RespBody
// @Router /answer/admin/api/answer/page [get]
func (qc *QuestionController) CmsSearchAnswerList(ctx *gin.Context) {

View File

@ -373,6 +373,27 @@ func (uc *UserController) UserUpdateInfo(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil)
}
// UserUpdateInterface update user interface config
// @Summary UserUpdateInterface update user interface config
// @Description UserUpdateInterface update user interface config
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param Authorization header string true "access-token"
// @Param data body schema.UpdateUserInterfaceRequest true "UpdateInfoRequest"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/user/interface [put]
func (uc *UserController) UserUpdateInterface(ctx *gin.Context) {
req := &schema.UpdateUserInterfaceRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserId = middleware.GetLoginUserIDFromContext(ctx)
err := uc.userService.UserUpdateInterface(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// UploadUserAvatar godoc
// @Summary UserUpdateInfo
// @Description UserUpdateInfo

View File

@ -40,11 +40,12 @@ type AnswerSearch struct {
}
type CmsAnswerSearch struct {
Page int `json:"page" form:"page"` // Query number of pages
PageSize int `json:"page_size" form:"page_size"` // Search page size
Status int `json:"-" form:"-"`
StatusStr string `json:"status" form:"status"` // Status 1 Available 2 closed 10 Deleted
Query string `validate:"omitempty,gt=0,lte=100" json:"query" form:"query" ` //Query string
Page int `json:"page" form:"page"` // Query number of pages
PageSize int `json:"page_size" form:"page_size"` // Search page size
Status int `json:"-" form:"-"`
StatusStr string `json:"status" form:"status"` // Status 1 Available 2 closed 10 Deleted
Query string `validate:"omitempty,gt=0,lte=100" json:"query" form:"query" ` //Query string
QuestionID string `validate:"omitempty,gt=0,lte=24" json:"question_id" form:"question_id" ` //Query string
}
type AdminSetAnswerStatusRequest struct {

View File

@ -45,6 +45,7 @@ type User struct {
Location string `xorm:"not null default '' VARCHAR(100) location"`
IPInfo string `xorm:"not null default '' VARCHAR(255) ip_info"`
IsAdmin bool `xorm:"not null default false BOOL is_admin"`
Language string `xorm:"not null default '' VARCHAR(100) language"`
}
// TableName user table name

View File

@ -5,7 +5,6 @@ import (
"github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/entity"
"github.com/segmentfault/pacman/log"
"xorm.io/xorm"
)
@ -43,6 +42,7 @@ var noopMigration = func(_ *xorm.Engine) error { return nil }
var migrations = []Migration{
// 0->1
NewMigration("this is first version, no operation", noopMigration),
NewMigration("add user language", addUserLanguage),
}
// GetCurrentDBVersion returns the current db version
@ -86,17 +86,17 @@ func Migrate(dataConf *data.Database) error {
expectedVersion := ExpectedVersion()
for currentDBVersion < expectedVersion {
log.Infof("[migrate] current db version is %d, try to migrate version %d, latest version is %d",
fmt.Printf("[migrate] current db version is %d, try to migrate version %d, latest version is %d\n",
currentDBVersion, currentDBVersion+1, expectedVersion)
migrationFunc := migrations[currentDBVersion]
log.Infof("[migrate] try to migrate db version %d, description: %s", currentDBVersion+1, migrationFunc.Description())
fmt.Printf("[migrate] try to migrate db version %d, description: %s\n", currentDBVersion+1, migrationFunc.Description())
if err := migrationFunc.Migrate(engine); err != nil {
log.Errorf("[migrate] migrate to db version %d failed: ", currentDBVersion+1, err.Error())
fmt.Printf("[migrate] migrate to db version %d failed: %s\n", currentDBVersion+1, err.Error())
return err
}
log.Infof("[migrate] migrate to db version %d success", currentDBVersion+1)
fmt.Printf("[migrate] migrate to db version %d success\n", currentDBVersion+1)
if _, err := engine.Update(&entity.Version{ID: 1, VersionNumber: currentDBVersion + 1}); err != nil {
log.Errorf("[migrate] migrate to db version %d, update failed: %s", currentDBVersion+1, err.Error())
fmt.Printf("[migrate] migrate to db version %d, update failed: %s", currentDBVersion+1, err.Error())
return err
}
currentDBVersion++

12
internal/migrations/v1.go Normal file
View File

@ -0,0 +1,12 @@
package migrations
import (
"xorm.io/xorm"
)
func addUserLanguage(x *xorm.Engine) error {
type User struct {
Language string `xorm:"not null default '' VARCHAR(100) language"`
}
return x.Sync(new(User))
}

View File

@ -268,6 +268,13 @@ func (ar *answerRepo) CmsSearchList(ctx context.Context, search *entity.CmsAnswe
}
}
// check search by question id
if len(search.QuestionID) > 0 {
session.And(builder.Eq{
"question_id": search.QuestionID,
})
}
offset := search.Page * search.PageSize
session.
OrderBy("a.updated_at desc").

View File

@ -101,6 +101,14 @@ func (ur *userRepo) UpdateEmail(ctx context.Context, userID, email string) (err
return
}
func (ur *userRepo) UpdateLanguage(ctx context.Context, userID, language string) (err error) {
_, err = ur.data.DB.Where("id = ?", userID).Update(&entity.User{Language: language})
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
return
}
// UpdateInfo update user info
func (ur *userRepo) UpdateInfo(ctx context.Context, userInfo *entity.User) (err error) {
_, err = ur.data.DB.Where("id = ?", userInfo.ID).

View File

@ -82,7 +82,7 @@ func NewAnswerAPIRouter(
func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
// i18n
r.GET("/language/config", a.langController.GetLangMapping)
r.GET("/language/options", a.langController.GetLangOptions)
r.GET("/language/options", a.langController.GetUserLangOptions)
// comment
r.GET("/comment/page", a.commentController.GetCommentWithPage)
@ -180,6 +180,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
// user
r.PUT("/user/password", a.userController.UserModifyPassWord)
r.PUT("/user/info", a.userController.UserUpdateInfo)
r.PUT("/user/interface", a.userController.UserUpdateInterface)
r.POST("/user/avatar/upload", a.userController.UploadUserAvatar)
r.POST("/user/post/file", a.userController.UploadUserPostFile)
r.POST("/user/notice/set", a.userController.UserNoticeSet)
@ -216,7 +217,7 @@ func (a *AnswerAPIRouter) RegisterAnswerCmsAPIRouter(r *gin.RouterGroup) {
r.GET("/reasons", a.reasonController.Reasons)
// language
r.GET("/language/options", a.langController.GetLangOptions)
r.GET("/language/options", a.langController.GetAdminLangOptions)
// theme
r.GET("/theme/options", a.themeController.GetThemeOptions)

View File

@ -7,6 +7,8 @@ import (
"net/http"
"os"
"github.com/answerdev/answer/i18n"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/ui"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
@ -40,6 +42,7 @@ func (r *_resource) Open(name string) (fs.File, error) {
// Register a new static resource which generated by ui directory
func (a *UIRouter) Register(r *gin.Engine) {
staticPath := os.Getenv("ANSWER_STATIC_PATH")
r.StaticFS("/i18n/", http.FS(i18n.I18n))
// if ANSWER_STATIC_PATH is set and not empty, ignore embed resource
if staticPath != "" {
@ -66,6 +69,59 @@ func (a *UIRouter) Register(r *gin.Engine) {
fs: ui.Build,
}))
// Install godoc
// @Summary Install
// @Description Install
// @Tags Install
// @Accept json
// @Produce json
// @Success 200 {object} handler.RespBody{}
// @Router /install [get]
r.GET("/install", func(c *gin.Context) {
filePath := ""
var file []byte
var err error
filePath = "build/index.html"
c.Header("content-type", "text/html;charset=utf-8")
file, err = ui.Build.ReadFile(filePath)
if err != nil {
log.Error(err)
c.Status(http.StatusNotFound)
return
}
c.String(http.StatusOK, string(file))
})
// Install godoc
// @Summary Install
// @Description Install
// @Tags Install
// @Accept json
// @Produce json
// @Param data body schema.FollowReq true "follow"
// @Success 200 {object} handler.RespBody{}
// @Router /install/db/check [put]
r.PUT("/install/db/check", func(c *gin.Context) {
handler.HandleResponse(c, nil, gin.H{})
})
// Install godoc
// @Summary Install
// @Description Install
// @Tags Install
// @Accept json
// @Produce json
// @Param data body schema.FollowReq true "follow"
// @Success 200 {object} handler.RespBody{}
// @Router /install [put]
r.PUT("/install", func(c *gin.Context) {
handler.HandleResponse(c, nil, gin.H{})
})
r.PUT("/install/siteconfig", func(c *gin.Context) {
handler.HandleResponse(c, nil, gin.H{})
})
// specify the not router for default routes and redirect
r.NoRoute(func(c *gin.Context) {
name := c.Request.URL.Path

View File

@ -1,18 +0,0 @@
package schema
// GetLangOption get label option
type GetLangOption struct {
Label string `json:"label"`
Value string `json:"value"`
}
var GetLangOptions = []*GetLangOption{
{
Label: "English(US)",
Value: "en_US",
},
{
Label: "中文(CN)",
Value: "zh_CN",
},
}

View File

@ -62,6 +62,8 @@ type GetUserResp struct {
Location string `json:"location"`
// ip info
IPInfo string `json:"ip_info"`
// language
Language string `json:"language"`
// access token
AccessToken string `json:"access_token"`
// is admin
@ -305,6 +307,14 @@ func (u *UpdateInfoRequest) Check() (errField *validator.ErrorField, err error)
return nil, nil
}
// UpdateUserInterfaceRequest update user interface request
type UpdateUserInterfaceRequest struct {
// language
Language string `validate:"required,gt=1,lte=100" json:"language"`
// user id
UserId string `json:"-" `
}
type UserRetrievePassWordRequest struct {
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` // e_mail
CaptchaID string `json:"captcha_id" ` // captcha_id

View File

@ -6,6 +6,7 @@ import (
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/export"
@ -73,10 +74,9 @@ func (s *SiteInfoService) SaveSiteGeneral(ctx context.Context, req schema.SiteGe
func (s *SiteInfoService) SaveSiteInterface(ctx context.Context, req schema.SiteInterfaceReq) (err error) {
var (
siteType = "interface"
themeExist,
langExist bool
content []byte
siteType = "interface"
themeExist bool
content []byte
)
// check theme
@ -92,13 +92,7 @@ func (s *SiteInfoService) SaveSiteInterface(ctx context.Context, req schema.Site
}
// check language
for _, lang := range schema.GetLangOptions {
if lang.Value == req.Language {
langExist = true
break
}
}
if !langExist {
if !translator.CheckLanguageIsValid(req.Language) {
err = errors.BadRequest(reason.LangNotFound)
return
}

View File

@ -15,6 +15,7 @@ type UserRepo interface {
UpdateEmailStatus(ctx context.Context, userID string, emailStatus int) error
UpdateNoticeStatus(ctx context.Context, userID string, noticeStatus int) error
UpdateEmail(ctx context.Context, userID, email string) error
UpdateLanguage(ctx context.Context, userID, language string) error
UpdatePass(ctx context.Context, userID, pass string) error
UpdateInfo(ctx context.Context, userInfo *entity.User) (err error)
GetByUserID(ctx context.Context, userID string) (userInfo *entity.User, exist bool, err error)

View File

@ -11,6 +11,7 @@ import (
"github.com/Chain-Zhang/pinyin"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/activity"
@ -283,6 +284,18 @@ func (us *UserService) UserEmailHas(ctx context.Context, email string) (bool, er
return has, nil
}
// UserUpdateInterface update user interface
func (us *UserService) UserUpdateInterface(ctx context.Context, req *schema.UpdateUserInterfaceRequest) (err error) {
if !translator.CheckLanguageIsValid(req.Language) {
return errors.BadRequest(reason.LangNotFound)
}
err = us.userRepo.UpdateLanguage(ctx, req.UserId, req.Language)
if err != nil {
return
}
return nil
}
// UserRegisterByEmail user register
func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo *schema.UserRegisterReq) (
resp *schema.GetUserResp, err error,

View File

@ -64,7 +64,7 @@ module.exports = {
position: 'before',
},
{
pattern: '@answer/**',
pattern: '@/**',
group: 'internal',
},
{

View File

@ -1,3 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
extends: ['@commitlint/routes-conventional'],
};

View File

@ -8,13 +8,6 @@ module.exports = {
config.resolve.alias = {
...config.resolve.alias,
'@': path.resolve(__dirname, 'src'),
'@answer/pages': path.resolve(__dirname, 'src/pages'),
'@answer/components': path.resolve(__dirname, 'src/components'),
'@answer/stores': path.resolve(__dirname, 'src/stores'),
'@answer/hooks': path.resolve(__dirname, 'src/hooks'),
'@answer/utils': path.resolve(__dirname, 'src/utils'),
'@answer/common': path.resolve(__dirname, 'src/common'),
'@answer/api': path.resolve(__dirname, 'src/services/api'),
};
return config;

View File

@ -1,8 +1,9 @@
import { RouterProvider } from 'react-router-dom';
import router from '@/router';
import { routes, createBrowserRouter } from '@/router';
function App() {
const router = createBrowserRouter(routes);
return <RouterProvider router={router} />;
}

View File

@ -1,9 +1,9 @@
export const LOGIN_NEED_BACK = [
'/users/login',
'/users/register',
'/users/account-recovery',
'/users/password-reset',
];
export const DEFAULT_LANG = 'en_US';
export const CURRENT_LANG_STORAGE_KEY = '_a_lang__';
export const LOGGED_USER_STORAGE_KEY = '_a_lui_';
export const LOGGED_TOKEN_STORAGE_KEY = '_a_ltk_';
export const REDIRECT_PATH_STORAGE_KEY = '_a_rp_';
export const CAPTCHA_CODE_STORAGE_KEY = '_a_captcha_';
export const ADMIN_LIST_STATUS = {
// normal;
@ -56,3 +56,229 @@ export const ADMIN_NAV_MENUS = [
child: [{ name: 'general' }, { name: 'interface' }, { name: 'smtp' }],
},
];
// timezones
export const TIMEZONES = [
{
label: 'UTC-12',
value: 'UTC-12',
},
{
label: 'UTC-11:30',
value: 'UTC-11.5',
},
{
label: 'UTC-11',
value: 'UTC-11',
},
{
label: 'UTC-10:30',
value: 'UTC-10.5',
},
{
label: 'UTC-10',
value: 'UTC-10',
},
{
label: 'UTC-9:30',
value: 'UTC-9.5',
},
{
label: 'UTC-9',
value: 'UTC-9',
},
{
label: 'UTC-8:30',
value: 'UTC-8.5',
},
{
label: 'UTC-8',
value: 'UTC-8',
},
{
label: 'UTC-7:30',
value: 'UTC-7.5',
},
{
label: 'UTC-7',
value: 'UTC-7',
},
{
label: 'UTC-6:30',
value: 'UTC-6.5',
},
{
label: 'UTC-6',
value: 'UTC-6',
},
{
label: 'UTC-5:30',
value: 'UTC-5.5',
},
{
label: 'UTC-5',
value: 'UTC-5',
},
{
label: 'UTC-4:30',
value: 'UTC-4.5',
},
{
label: 'UTC-4',
value: 'UTC-4',
},
{
label: 'UTC-3:30',
value: 'UTC-3.5',
},
{
label: 'UTC-3',
value: 'UTC-3',
},
{
label: 'UTC-2:30',
value: 'UTC-2.5',
},
{
label: 'UTC-2',
value: 'UTC-2',
},
{
label: 'UTC-1:30',
value: 'UTC-1.5',
},
{
label: 'UTC-1',
value: 'UTC-1',
},
{
label: 'UTC-0:30',
value: 'UTC-0.5',
},
{
label: 'UTC+0',
value: 'UTC+0',
},
{
label: 'UTC+0:30',
value: 'UTC+0.5',
},
{
label: 'UTC+1',
value: 'UTC+1',
},
{
label: 'UTC+1:30',
value: 'UTC+1.5',
},
{
label: 'UTC+2',
value: 'UTC+2',
},
{
label: 'UTC+2:30',
value: 'UTC+2.5',
},
{
label: 'UTC+3',
value: 'UTC+3',
},
{
label: 'UTC+3:30',
value: 'UTC+3.5',
},
{
label: 'UTC+4',
value: 'UTC+4',
},
{
label: 'UTC+4:30',
value: 'UTC+4.5',
},
{
label: 'UTC+5',
value: 'UTC+5',
},
{
label: 'UTC+5:30',
value: 'UTC+5.5',
},
{
label: 'UTC+5:45',
value: 'UTC+5.75',
},
{
label: 'UTC+6',
value: 'UTC+6',
},
{
label: 'UTC+6:30',
value: 'UTC+6.5',
},
{
label: 'UTC+7',
value: 'UTC+7',
},
{
label: 'UTC+7:30',
value: 'UTC+7.5',
},
{
label: 'UTC+8',
value: 'UTC+8',
},
{
label: 'UTC+8:30',
value: 'UTC+8.5',
},
{
label: 'UTC+8:45',
value: 'UTC+8.75',
},
{
label: 'UTC+9',
value: 'UTC+9',
},
{
label: 'UTC+9:30',
value: 'UTC+9.5',
},
{
label: 'UTC+10',
value: 'UTC+10',
},
{
label: 'UTC+10:30',
value: 'UTC+10.5',
},
{
label: 'UTC+11',
value: 'UTC+11',
},
{
label: 'UTC+11:30',
value: 'UTC+11.5',
},
{
label: 'UTC+12',
value: 'UTC+12',
},
{
label: 'UTC+12:45',
value: 'UTC+12.75',
},
{
label: 'UTC+13',
value: 'UTC+13',
},
{
label: 'UTC+13:45',
value: 'UTC+13.75',
},
{
label: 'UTC+14',
value: 'UTC+14',
},
];
export const DEFAULT_TIMEZONE = 'UTC+0';

View File

@ -109,7 +109,7 @@ export interface UserInfoBase {
*/
status?: string;
/** roles */
is_admin?: true;
is_admin?: boolean;
}
export interface UserInfoRes extends UserInfoBase {
@ -228,6 +228,7 @@ export type AdminContentsFilterBy = 'normal' | 'closed' | 'deleted';
export interface AdminContentsReq extends Paging {
status: AdminContentsFilterBy;
query?: string;
}
/**
@ -263,6 +264,7 @@ export interface AdminSettingsInterface {
logo: string;
language: string;
theme: string;
time_zone?: string;
}
export interface AdminSettingsSmtp {
@ -321,3 +323,21 @@ export interface SearchResItem {
export interface SearchRes extends ListResult<SearchResItem> {
extra: any;
}
export interface AdminDashboard {
info: {
question_count: number;
answer_count: number;
comment_count: number;
vote_count: number;
user_count: number;
report_count: number;
uploading_files: boolean;
smtp: boolean;
time_zone: string;
occupying_storage_space: string;
app_start_time: number;
app_version: string;
https: boolean;
};
}

View File

@ -5,7 +5,7 @@ import { useNavigate, useMatch } from 'react-router-dom';
import { useAccordionButton } from 'react-bootstrap/AccordionButton';
import { Icon } from '@answer/components';
import { Icon } from '@/components';
function MenuNode({ menu, callback, activeKey, isLeaf = false }) {
const { t } = useTranslation('translation', { keyPrefix: 'admin.nav_menus' });

View File

@ -4,11 +4,11 @@ import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { Icon } from '@answer/components';
import { bookmark, postVote } from '@answer/api';
import { isLogin } from '@answer/utils';
import { userInfoStore } from '@answer/stores';
import { useToast } from '@answer/hooks';
import { Icon } from '@/components';
import { loggedUserInfoStore } from '@/stores';
import { useToast } from '@/hooks';
import { tryNormalLogged } from '@/utils/guard';
import { bookmark, postVote } from '@/services';
interface Props {
className?: string;
@ -32,7 +32,7 @@ const Index: FC<Props> = ({ className, data }) => {
state: data?.collected,
count: data?.collectCount,
});
const { username = '' } = userInfoStore((state) => state.user);
const { username = '' } = loggedUserInfoStore((state) => state.user);
const toast = useToast();
const { t } = useTranslation();
useEffect(() => {
@ -48,7 +48,7 @@ const Index: FC<Props> = ({ className, data }) => {
}, []);
const handleVote = (type: 'up' | 'down') => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
@ -84,7 +84,7 @@ const Index: FC<Props> = ({ className, data }) => {
};
const handleBookmark = () => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
bookmark({

View File

@ -21,9 +21,9 @@ const Index: FC<IProps> = ({ avatar, size, className, searchStr = '' }) => {
avatar?.includes('gravatar') ? '&d=identicon' : ''
}`;
}
} else if (avatar?.type === 'gravatar') {
} else if (avatar?.type === 'gravatar' && avatar.gravatar) {
url = `${avatar.gravatar}?${searchStr}&d=identicon`;
} else if (avatar?.type === 'custom') {
} else if (avatar?.type === 'custom' && avatar.custom) {
url = `${avatar.custom}?${searchStr}`;
}

View File

@ -1,8 +1,7 @@
import { memo, FC } from 'react';
import { Link } from 'react-router-dom';
import { Avatar } from '@answer/components';
import { Avatar } from '@/components';
import { formatCount } from '@/utils';
interface Props {

View File

@ -5,7 +5,7 @@ import { Link } from 'react-router-dom';
import classNames from 'classnames';
import { Icon, FormatTime } from '@answer/components';
import { Icon, FormatTime } from '@/components';
const ActionBar = ({
nickName,

View File

@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { TextArea, Mentions } from '@answer/components';
import { usePageUsers } from '@answer/hooks';
import { TextArea, Mentions } from '@/components';
import { usePageUsers } from '@/hooks';
const Form = ({
className = '',

View File

@ -2,8 +2,8 @@ import { useState, memo } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { TextArea, Mentions } from '@answer/components';
import { usePageUsers } from '@answer/hooks';
import { TextArea, Mentions } from '@/components';
import { usePageUsers } from '@/hooks';
const Form = ({ userName, onSendReply, onCancel, mode }) => {
const [value, setValue] = useState('');

View File

@ -7,17 +7,18 @@ import classNames from 'classnames';
import { unionBy } from 'lodash';
import { marked } from 'marked';
import * as Types from '@answer/common/interface';
import * as Types from '@/common/interface';
import { Modal } from '@/components';
import { usePageUsers, useReportModal } from '@/hooks';
import { matchedUsers, parseUserInfo } from '@/utils';
import { tryNormalLogged } from '@/utils/guard';
import {
useQueryComments,
addComment,
deleteComment,
updateComment,
postVote,
} from '@answer/api';
import { Modal } from '@answer/components';
import { usePageUsers, useReportModal } from '@answer/hooks';
import { matchedUsers, parseUserInfo, isLogin } from '@answer/utils';
} from '@/services';
import { Form, ActionBar, Reply } from './components';
@ -163,7 +164,7 @@ const Comment = ({ objectId, mode }) => {
};
const handleVote = (id, is_cancel) => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
@ -189,7 +190,7 @@ const Comment = ({ objectId, mode }) => {
};
const handleAction = ({ action }, item) => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
if (action === 'report') {

View File

@ -2,10 +2,10 @@ import { FC, useEffect, useState, memo } from 'react';
import { Button, Form, Modal, Tab, Tabs } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Modal as AnswerModal } from '@answer/components';
import { uploadImage } from '@answer/api';
import { Modal as AnswerModal } from '@/components';
import ToolItem from '../toolItem';
import { IEditorContext } from '../types';
import { uploadImage } from '@/services';
const Image: FC<IEditorContext> = ({ editor }) => {
const { t } = useTranslation('translation', { keyPrefix: 'editor' });

View File

@ -3,9 +3,9 @@ import { Card, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router-dom';
import { TagSelector, Tag } from '@answer/components';
import { isLogin } from '@answer/utils';
import { useFollowingTags, followTags } from '@answer/api';
import { TagSelector, Tag } from '@/components';
import { tryNormalLogged } from '@/utils/guard';
import { useFollowingTags, followTags } from '@/services';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
@ -32,7 +32,7 @@ const Index: FC = () => {
});
};
if (!isLogin()) {
if (!tryNormalLogged()) {
return null;
}

View File

@ -3,7 +3,7 @@ import { Nav, Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, NavLink } from 'react-router-dom';
import { Avatar, Icon } from '@answer/components';
import { Avatar, Icon } from '@/components';
interface Props {
redDot;

View File

@ -50,6 +50,10 @@
@media (max-width: 992.9px) {
#header {
.logo {
max-width: 93px;
max-height: auto;
}
.nav-grow {
flex-grow: 1!important;
}

View File

@ -17,9 +17,9 @@ import {
useLocation,
} from 'react-router-dom';
import { userInfoStore, siteInfoStore, interfaceStore } from '@answer/stores';
import { logout, useQueryNotificationStatus } from '@answer/api';
import Storage from '@answer/utils/storage';
import { loggedUserInfoStore, siteInfoStore, interfaceStore } from '@/stores';
import { logout, useQueryNotificationStatus } from '@/services';
import { RouteAlias } from '@/router/alias';
import NavItems from './components/NavItems';
@ -27,7 +27,7 @@ import './index.scss';
const Header: FC = () => {
const navigate = useNavigate();
const { user, clear } = userInfoStore();
const { user, clear } = loggedUserInfoStore();
const { t } = useTranslation();
const [urlSearch] = useSearchParams();
const q = urlSearch.get('q');
@ -42,9 +42,8 @@ const Header: FC = () => {
const handleLogout = async () => {
await logout();
Storage.remove('token');
clear();
navigate('/');
navigate(RouteAlias.home);
};
useEffect(() => {

View File

@ -3,8 +3,8 @@ import { Card, ListGroup, ListGroupItem } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useHotQuestions } from '@answer/api';
import { Icon } from '@answer/components';
import { Icon } from '@/components';
import { useHotQuestions } from '@/services';
const HotQuestions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState, FC } from 'react';
import { Dropdown } from 'react-bootstrap';
import * as Types from '@answer/common/interface';
import * as Types from '@/common/interface';
interface IProps {
children: React.ReactNode;

View File

@ -2,12 +2,10 @@ import React from 'react';
import { Modal, Form, Button, InputGroup } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Icon } from '@answer/components';
import type {
FormValue,
FormDataType,
ImgCodeRes,
} from '@answer/common/interface';
import { Icon } from '@/components';
import type { FormValue, FormDataType, ImgCodeRes } from '@/common/interface';
import { CAPTCHA_CODE_STORAGE_KEY } from '@/common/constants';
import Storage from '@/utils/storage';
interface IProps {
/** control visible */
@ -55,7 +53,7 @@ const Index: React.FC<IProps> = ({
placeholder={t('placeholder')}
isInvalid={captcha.isInvalid}
onChange={(e) => {
localStorage.setItem('captchaCode', e.target.value);
Storage.set(CAPTCHA_CODE_STORAGE_KEY, e.target.value);
handleCaptcha({
captcha_code: {
value: e.target.value,

View File

@ -3,11 +3,11 @@ import { Button } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Modal } from '@answer/components';
import { useReportModal, useToast } from '@answer/hooks';
import { deleteQuestion, deleteAnswer } from '@answer/api';
import { isLogin } from '@answer/utils';
import { Modal } from '@/components';
import { useReportModal, useToast } from '@/hooks';
import Share from '../Share';
import { deleteQuestion, deleteAnswer } from '@/services';
import { tryNormalLogged } from '@/utils/guard';
interface IProps {
type: 'answer' | 'question';
@ -98,7 +98,7 @@ const Index: FC<IProps> = ({
};
const handleAction = (action) => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
if (action === 'delete') {

View File

@ -3,8 +3,7 @@ import { Row, Col, ListGroup } from 'react-bootstrap';
import { NavLink, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuestionList } from '@answer/api';
import type * as Type from '@answer/common/interface';
import type * as Type from '@/common/interface';
import {
Icon,
Tag,
@ -13,7 +12,8 @@ import {
Empty,
BaseUserCard,
QueryGroup,
} from '@answer/components';
} from '@/components';
import { useQuestionList } from '@/services';
const QuestionOrderKeys: Type.QuestionOrderBy[] = [
'newest',

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { FacebookShareButton, TwitterShareButton } from 'next-share';
import copy from 'copy-to-clipboard';
import { userInfoStore } from '@answer/stores';
import { loggedUserInfoStore } from '@/stores';
interface IProps {
type: 'answer' | 'question';
@ -15,7 +15,7 @@ interface IProps {
}
const Index: FC<IProps> = ({ type, qid, aid, title }) => {
const user = userInfoStore((state) => state.user);
const user = loggedUserInfoStore((state) => state.user);
const [show, setShow] = useState(false);
const [showTip, setShowTip] = useState(false);
const [canSystemShare, setSystemShareState] = useState(false);

View File

@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next';
import { marked } from 'marked';
import classNames from 'classnames';
import { useTagModal } from '@answer/hooks';
import { queryTags } from '@answer/api';
import type * as Type from '@answer/common/interface';
import { useTagModal } from '@/hooks';
import type * as Type from '@/common/interface';
import { queryTags } from '@/services';
import './index.scss';

View File

@ -3,14 +3,12 @@ import { Button, Col } from 'react-bootstrap';
import { Trans, useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { resendEmail, checkImgCode } from '@answer/api';
import { PicAuthCodeModal } from '@answer/components/Modal';
import type {
ImgCodeRes,
ImgCodeReq,
FormDataType,
} from '@answer/common/interface';
import { userInfoStore } from '@answer/stores';
import { PicAuthCodeModal } from '@/components/Modal';
import type { ImgCodeRes, ImgCodeReq, FormDataType } from '@/common/interface';
import { loggedUserInfoStore } from '@/stores';
import { resendEmail, checkImgCode } from '@/services';
import { CAPTCHA_CODE_STORAGE_KEY } from '@/common/constants';
import Storage from '@/utils/storage';
interface IProps {
visible: boolean;
@ -20,7 +18,7 @@ const Index: React.FC<IProps> = ({ visible = false }) => {
const { t } = useTranslation('translation', { keyPrefix: 'inactive' });
const [isSuccess, setSuccess] = useState(false);
const [showModal, setModalState] = useState(false);
const { e_mail } = userInfoStore((state) => state.user);
const { e_mail } = loggedUserInfoStore((state) => state.user);
const [formData, setFormData] = useState<FormDataType>({
captcha_code: {
value: '',
@ -48,7 +46,7 @@ const Index: React.FC<IProps> = ({ visible = false }) => {
}
let obj: ImgCodeReq = {};
if (imgCode.verify) {
const code = localStorage.getItem('captchaCode') || '';
const code = Storage.get(CAPTCHA_CODE_STORAGE_KEY) || '';
obj = {
captcha_code: code,
captcha_id: imgCode.captcha_id,

View File

@ -3,8 +3,7 @@ import { Link } from 'react-router-dom';
import classnames from 'classnames';
import { Avatar, FormatTime } from '@answer/components';
import { Avatar, FormatTime } from '@/components';
import { formatCount } from '@/utils';
interface Props {

View File

@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next';
import ReactDOM from 'react-dom/client';
import { changeUserStatus } from '@answer/api';
import { Modal as AnswerModal } from '@answer/components';
import { Modal as AnswerModal } from '@/components';
import { changeUserStatus } from '@/services';
const div = document.createElement('div');
const root = ReactDOM.createRoot(div);

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { uniqBy } from 'lodash';
import * as Types from '@answer/common/interface';
import * as Types from '@/common/interface';
let globalUsers: Types.PageUser[] = [];
const usePageUsers = () => {

View File

@ -4,9 +4,9 @@ import { useTranslation } from 'react-i18next';
import ReactDOM from 'react-dom/client';
import { reportList, postReport, closeQuestion, putReport } from '@answer/api';
import { useToast } from '@answer/hooks';
import type * as Type from '@answer/common/interface';
import { useToast } from '@/hooks';
import type * as Type from '@/common/interface';
import { reportList, postReport, closeQuestion, putReport } from '@/services';
interface Params {
isBackend?: boolean;

View File

@ -3,6 +3,8 @@ import { initReactI18next } from 'react-i18next';
import i18next from 'i18next';
import Backend from 'i18next-http-backend';
import { DEFAULT_LANG } from '@/common/constants';
import en from './locales/en.json';
import zh from './locales/zh_CN.json';
@ -21,7 +23,7 @@ i18next
},
},
// debug: process.env.NODE_ENV === 'development',
fallbackLng: process.env.REACT_APP_LANG || 'en_US',
fallbackLng: process.env.REACT_APP_LANG || DEFAULT_LANG,
interpolation: {
escapeValue: false,
},

View File

@ -28,7 +28,10 @@
"confirm_email": "Confirm Email",
"account_suspended": "Account Suspended",
"admin": "Admin",
"change_email": "Modify Email"
"change_email": "Modify Email",
"install": "Answer Installation",
"upgrade": "Answer Upgrade",
"maintenance": "Webite Maintenance"
},
"notifications": {
"title": "Notifications",
@ -290,7 +293,9 @@
"now": "now",
"x_seconds_ago": "{{count}}s ago",
"x_minutes_ago": "{{count}}m ago",
"x_hours_ago": "{{count}}h ago"
"x_hours_ago": "{{count}}h ago",
"hour": "hour",
"day": "day"
},
"comment": {
"btn_add_comment": "Add comment",
@ -523,7 +528,8 @@
"custom": "Custom",
"btn_refresh": "Refresh",
"custom_text": "You can upload your image.",
"default": "Default"
"default": "Default",
"msg": "Please upload an avatar"
},
"bio": {
"label": "About Me (optional)"
@ -734,6 +740,84 @@
"x_answers": "answers",
"x_questions": "questions"
},
"install": {
"title": "Answer",
"next": "Next",
"done": "Done",
"lang": {
"label": "Please choose a language"
},
"db_type": {
"label": "Database Engine"
},
"db_username": {
"label": "Username",
"placeholder": "root",
"msg": "Username cannot be empty."
},
"db_password": {
"label": "Password",
"placeholder": "root",
"msg": "Password cannot be empty."
},
"db_host": {
"label": "Database Host",
"placeholder": "db:3306",
"msg": "Database Host cannot be empty."
},
"db_name": {
"label": "Database Name",
"placeholder": "answer",
"msg": "Database Name cannot be empty."
},
"db_file": {
"label": "Database File",
"placeholder": "/data/answer.db",
"msg": "Database File cannot be empty."
},
"config_yaml": {
"title": "Create config.yaml",
"label": "The config.yaml file created.",
"description": "You can create the <1>config.yaml</1> file manually in the <1>/var/wwww/xxx/</1> directory and paste the following text into it.",
"info": "After youve done that, click “Next” button."
},
"site_information": "Site Information",
"admin_account": "Admin Account",
"site_name": {
"label": "Site Name"
},
"contact_email": {
"label": "Contact Email",
"text": "Email address of key contact responsible for this site."
},
"admin_name": {
"label": "Name"
},
"admin_password": {
"label": "Password",
"text": "You will need this password to log in. Please store it in a secure location."
},
"admin_email": {
"label": "Email",
"text": "You will need this email to log in."
},
"ready_title": "Your Answer is Ready!",
"ready_description": "If you ever feel like changing more settings, visit <1>admin section</1>; find it in the site menu.",
"good_luck": "Have fun, and good luck!",
"warning": "Warning",
"warning_description": "The file <1>config.yaml</1> already exists. If you need to reset any of the configuration items in this file, please delete it first. You may try <2>installing now</2>.",
"installed": "Already installed",
"installed_description": "You appear to have already installed. To reinstall please clear your old database tables first."
},
"upgrade": {
"title": "Answer",
"update_btn": "Update data",
"update_title": "Data update required",
"update_description": "<1>Answer has been updated! Before you continue, we have to update your data to the newest version.</1><1>The update process may take a little while, so please be patient.</1>",
"done_title": "No update required",
"done_btn": "Done",
"done_desscription": "Your Answer data is already up-to-date."
},
"page_404": {
"description": "Unfortunately, this page doesn't exist.",
"back_home": "Back to homepage"
@ -742,6 +826,9 @@
"description": "The server encountered an error and could not complete your request.",
"back_home": "Back to homepage"
},
"page_maintenance": {
"description": "We are under maintenance, well be back soon."
},
"admin": {
"admin_header": {
"title": "Admin"
@ -761,7 +848,36 @@
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome to Answer Admin !",
"version": "Version"
"site_statistics": "Site Statistics",
"questions": "Questions:",
"answers": "Answers:",
"comments": "Comments:",
"votes": "Votes:",
"active_users": "Active users:",
"flags": "Flags:",
"site_health_status": "Site Health Status",
"version": "Version:",
"https": "HTTPS:",
"uploading_files": "Uploading files:",
"smtp": "SMTP:",
"timezone": "Timezone:",
"system_info": "System Info",
"storage_used": "Storage used:",
"uptime": "Uptime:",
"answer_links": "Answer Links",
"documents": "Documents",
"feedback": "Feedback",
"review": "Review",
"config": "Config",
"update_to": "Update to",
"latest": "Latest",
"check_failed": "Check failed",
"yes": "Yes",
"no": "No",
"not_allowed": "Not allowed",
"allowed": "Allowed",
"enabled": "Enabled",
"disabled": "Disabled"
},
"flags": {
"title": "Flags",
@ -818,7 +934,10 @@
"inactive": "Inactive",
"suspended": "Suspended",
"deleted": "Deleted",
"normal": "Normal"
"normal": "Normal",
"filter": {
"placeholder": "Filter by name, user:id"
}
},
"questions": {
"page_title": "Questions",
@ -831,7 +950,10 @@
"created": "Created",
"status": "Status",
"action": "Action",
"change": "Change"
"change": "Change",
"filter": {
"placeholder": "Filter by title, question:id"
}
},
"answers": {
"page_title": "Answers",
@ -842,7 +964,10 @@
"created": "Created",
"status": "Status",
"action": "Action",
"change": "Change"
"change": "Change",
"filter": {
"placeholder": "Filter by title, answer:id"
}
},
"general": {
"page_title": "General",
@ -878,6 +1003,11 @@
"label": "Interface Language",
"msg": "Interface language cannot be empty.",
"text": "User interface language. It will change when you refresh the page."
},
"timezone": {
"label": "Timezone",
"msg": "Timezone cannot be empty.",
"text": "Choose a UTC (Coordinated Universal Time) time offset."
}
},
"smtp": {

View File

@ -77,6 +77,10 @@ a {
.page-wrap {
min-height: calc(100vh - 148px);
}
.page-wrap2 {
background-color: #f5f5f5;
min-height: 100vh;
}
.btn-no-border,
.btn-no-border:hover,

View File

@ -2,15 +2,27 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { Guard } from '@/utils';
import App from './App';
import './i18n/init';
import './index.scss';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
async function bootstrapApp() {
/**
* NOTICE: must pre init logged user info for router
*/
await Guard.pullLoggedUser();
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
bootstrapApp();

View File

@ -11,21 +11,23 @@ import {
BaseUserCard,
Empty,
QueryGroup,
} from '@answer/components';
import { ADMIN_LIST_STATUS } from '@answer/common/constants';
import { useEditStatusModal } from '@answer/hooks';
import { useAnswerSearch, changeAnswerStatus } from '@answer/api';
import * as Type from '@answer/common/interface';
} from '@/components';
import { ADMIN_LIST_STATUS } from '@/common/constants';
import { useEditStatusModal } from '@/hooks';
import * as Type from '@/common/interface';
import { useAnswerSearch, changeAnswerStatus } from '@/services';
import '../index.scss';
const answerFilterItems: Type.AdminContentsFilterBy[] = ['normal', 'deleted'];
const Answers: FC = () => {
const [urlSearchParams] = useSearchParams();
const [urlSearchParams, setUrlSearchParams] = useSearchParams();
const curFilter = urlSearchParams.get('status') || answerFilterItems[0];
const PAGE_SIZE = 20;
const curPage = Number(urlSearchParams.get('page')) || 1;
const curQuery = urlSearchParams.get('query') || '';
const questionId = urlSearchParams.get('questionId') || '';
const { t } = useTranslation('translation', { keyPrefix: 'admin.answers' });
const {
@ -36,6 +38,8 @@ const Answers: FC = () => {
page_size: PAGE_SIZE,
page: curPage,
status: curFilter as Type.AdminContentsFilterBy,
query: curQuery,
question_id: questionId,
});
const count = listData?.count || 0;
@ -77,6 +81,11 @@ const Answers: FC = () => {
});
};
const handleFilter = (e) => {
urlSearchParams.set('query', e.target.value);
urlSearchParams.delete('page');
setUrlSearchParams(urlSearchParams);
};
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
@ -89,19 +98,20 @@ const Answers: FC = () => {
/>
<Form.Control
value={curQuery}
onChange={handleFilter}
size="sm"
type="input"
placeholder="Filter by title"
className="d-none"
placeholder={t('filter.placeholder')}
style={{ width: '12.25rem' }}
/>
</div>
<Table>
<Table responsive>
<thead>
<tr>
<th style={{ width: '45%' }}>{t('post')}</th>
<th>{t('post')}</th>
<th>{t('votes')}</th>
<th style={{ width: '20%' }}>{t('created')}</th>
<th>{t('created')}</th>
<th>{t('status')}</th>
{curFilter !== 'deleted' && <th>{t('action')}</th>}
</tr>
@ -132,6 +142,7 @@ const Answers: FC = () => {
__html: li.description,
}}
className="last-p text-truncate-2 fs-14"
style={{ maxWidth: '30rem' }}
/>
</Stack>
</td>

View File

@ -0,0 +1,31 @@
import { Card, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
const AnswerLinks = () => {
const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' });
return (
<Card className="mb-4">
<Card.Body>
<h6 className="mb-3">{t('answer_links')}</h6>
<Row>
<Col xs={6}>
<a href="https://answer.dev" target="_blank" rel="noreferrer">
{t('documents')}
</a>
</Col>
<Col xs={6}>
<a
href="https://github.com/answerdev/answer/issues"
target="_blank"
rel="noreferrer">
{t('feedback')}
</a>
</Col>
</Row>
</Card.Body>
</Card>
);
};
export default AnswerLinks;

View File

@ -0,0 +1,54 @@
import { FC } from 'react';
import { Card, Row, Col, Badge } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type * as Type from '@/common/interface';
interface IProps {
data: Type.AdminDashboard['info'];
}
const HealthStatus: FC<IProps> = ({ data }) => {
const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' });
return (
<Card className="mb-4">
<Card.Body>
<h6 className="mb-3">{t('site_health_status')}</h6>
<Row>
<Col xs={6} className="mb-1 d-flex align-items-center">
<span className="text-secondary me-1">{t('version')}</span>
<strong>90</strong>
<Badge pill bg="warning" text="dark" className="ms-1">
{t('update_to')} {data.app_version}
</Badge>
</Col>
<Col xs={6} className="mb-1">
<span className="text-secondary me-1">{t('https')}</span>
<strong>{data.https ? t('yes') : t('yes')}</strong>
</Col>
<Col xs={6} className="mb-1">
<span className="text-secondary me-1">{t('uploading_files')}</span>
<strong>
{data.uploading_files ? t('allowed') : t('not_allowed')}
</strong>
</Col>
<Col xs={6}>
<span className="text-secondary me-1">{t('smtp')}</span>
<strong>{data.smtp ? t('enabled') : t('disabled')}</strong>
<Link to="/admin/smtp" className="ms-2">
{t('config')}
</Link>
</Col>
<Col xs={6}>
<span className="text-secondary me-1">{t('timezone')}</span>
<strong>{data.time_zone}</strong>
</Col>
</Row>
</Card.Body>
</Card>
);
};
export default HealthStatus;

View File

@ -0,0 +1,51 @@
import { FC } from 'react';
import { Card, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
interface IProps {
data: Type.AdminDashboard['info'];
}
const Statistics: FC<IProps> = ({ data }) => {
const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' });
return (
<Card className="mb-4">
<Card.Body>
<h6 className="mb-3">{t('site_statistics')}</h6>
<Row>
<Col xs={6} className="mb-1">
<span className="text-secondary me-1">{t('questions')}</span>
<strong>{data.question_count}</strong>
</Col>
<Col xs={6} className="mb-1">
<span className="text-secondary me-1">{t('answers')}</span>
<strong>{data.answer_count}</strong>
</Col>
<Col xs={6} className="mb-1">
<span className="text-secondary me-1">{t('comments')}</span>
<strong>{data.comment_count}</strong>
</Col>
<Col xs={6} className="mb-1">
<span className="text-secondary me-1">{t('votes')}</span>
<strong>{data.vote_count}</strong>
</Col>
<Col xs={6}>
<span className="text-secondary me-1">{t('active_users')}</span>
<strong>{data.user_count}</strong>
</Col>
<Col xs={6}>
<span className="text-secondary me-1">{t('flags')}</span>
<strong>{data.report_count}</strong>
<a href="###" className="ms-2">
{t('review')}
</a>
</Col>
</Row>
</Card.Body>
</Card>
);
};
export default Statistics;

View File

@ -0,0 +1,33 @@
import { FC } from 'react';
import { Card, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { formatUptime } from '@/utils';
interface IProps {
data: Type.AdminDashboard['info'];
}
const SystemInfo: FC<IProps> = ({ data }) => {
const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' });
return (
<Card className="mb-4">
<Card.Body>
<h6 className="mb-3">{t('system_info')}</h6>
<Row>
<Col xs={6}>
<span className="text-secondary me-1">{t('storage_used')}</span>
<strong>{data.occupying_storage_space}</strong>
</Col>
<Col xs={6}>
<span className="text-secondary me-1">{t('uptime')}</span>
<strong>{formatUptime(data.app_start_time)}</strong>
</Col>
</Row>
</Card.Body>
</Card>
);
};
export default SystemInfo;

View File

@ -0,0 +1,6 @@
import SystemInfo from './SystemInfo';
import Statistics from './Statistics';
import AnswerLinks from './AnswerLinks';
import HealthStatus from './HealthStatus';
export { SystemInfo, Statistics, AnswerLinks, HealthStatus };

View File

@ -1,12 +1,41 @@
import { FC } from 'react';
import { Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useDashBoard } from '@/services';
import {
AnswerLinks,
HealthStatus,
Statistics,
SystemInfo,
} from './components';
const Dashboard: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' });
const { data } = useDashBoard();
if (!data) {
return null;
}
return (
<>
<h3 className="text-capitalize">{t('title')}</h3>
<p className="mt-4">{t('welcome')}</p>
<Row>
<Col lg={6}>
<Statistics data={data.info} />
</Col>
<Col lg={6}>
<HealthStatus data={data.info} />
</Col>
<Col lg={6}>
<SystemInfo data={data.info} />
</Col>
<Col lg={6}>
<AnswerLinks />
</Col>
</Row>
{process.env.REACT_APP_VERSION && (
<p className="mt-4">
{`${t('version')} `}

View File

@ -9,10 +9,10 @@ import {
Empty,
Pagination,
QueryGroup,
} from '@answer/components';
import { useReportModal } from '@answer/hooks';
import * as Type from '@answer/common/interface';
import { useFlagSearch } from '@answer/api';
} from '@/components';
import { useReportModal } from '@/hooks';
import * as Type from '@/common/interface';
import { useFlagSearch } from '@/services';
import '../index.scss';

View File

@ -2,10 +2,10 @@ import React, { FC, useEffect, useState } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import type * as Type from '@answer/common/interface';
import { useToast } from '@answer/hooks';
import { siteInfoStore } from '@answer/stores';
import { useGeneralSetting, updateGeneralSetting } from '@answer/api';
import type * as Type from '@/common/interface';
import { useToast } from '@/hooks';
import { siteInfoStore } from '@/stores';
import { useGeneralSetting, updateGeneralSetting } from '@/services';
import '../index.scss';

View File

@ -2,21 +2,22 @@ import React, { FC, FormEvent, useEffect, useState } from 'react';
import { Form, Button, Image, Stack } from 'react-bootstrap';
import { Trans, useTranslation } from 'react-i18next';
import { useToast } from '@answer/hooks';
import { useToast } from '@/hooks';
import {
LangsType,
FormDataType,
AdminSettingsInterface,
} from '@answer/common/interface';
} from '@/common/interface';
import { interfaceStore } from '@/stores';
import { UploadImg } from '@/components';
import { TIMEZONES, DEFAULT_TIMEZONE } from '@/common/constants';
import {
languages,
uploadAvatar,
updateInterfaceSetting,
useInterfaceSetting,
useThemeOptions,
} from '@answer/api';
import { interfaceStore } from '@answer/stores';
import { UploadImg } from '@answer/components';
} from '@/services';
const Interface: FC = () => {
const { t } = useTranslation('translation', {
@ -27,6 +28,7 @@ const Interface: FC = () => {
const Toast = useToast();
const [langs, setLangs] = useState<LangsType[]>();
const { data: setting } = useInterfaceSetting();
const [formData, setFormData] = useState<FormDataType>({
logo: {
value: setting?.logo || '',
@ -43,6 +45,11 @@ const Interface: FC = () => {
isInvalid: false,
errorMsg: '',
},
time_zone: {
value: setting?.time_zone || DEFAULT_TIMEZONE,
isInvalid: false,
errorMsg: '',
},
});
const getLangs = async () => {
const res: LangsType[] = await languages();
@ -106,6 +113,7 @@ const Interface: FC = () => {
logo: formData.logo.value,
theme: formData.theme.value,
language: formData.language.value,
time_zone: formData.time_zone.value,
};
updateInterfaceSetting(reqParams)
@ -158,12 +166,14 @@ const Interface: FC = () => {
Object.keys(setting).forEach((k) => {
formMeta[k] = { ...formData[k], value: setting[k] };
});
setFormData(formMeta);
setFormData({ ...formData, ...formMeta });
}
}, [setting]);
useEffect(() => {
getLangs();
}, []);
console.log('formData', formData);
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
@ -249,7 +259,27 @@ const Interface: FC = () => {
{formData.language.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="time-zone" className="mb-3">
<Form.Label>{t('time_zone.label')}</Form.Label>
<Form.Select
value={formData.time_zone.value}
isInvalid={formData.time_zone.isInvalid}
onChange={(evt) => {
onChange('time_zone', evt.target.value);
}}>
{TIMEZONES?.map((item) => {
return (
<option value={item.value} key={item.value}>
{item.label}
</option>
);
})}
</Form.Select>
<Form.Text as="div">{t('time_zone.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{formData.time_zone.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Button variant="primary" type="submit">
{t('save', { keyPrefix: 'btns' })}
</Button>

View File

@ -1,6 +1,6 @@
import { FC } from 'react';
import { Button, Form, Table, Stack, Badge } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@ -11,15 +11,15 @@ import {
BaseUserCard,
Empty,
QueryGroup,
} from '@answer/components';
import { ADMIN_LIST_STATUS } from '@answer/common/constants';
import { useEditStatusModal, useReportModal } from '@answer/hooks';
} from '@/components';
import { ADMIN_LIST_STATUS } from '@/common/constants';
import { useEditStatusModal, useReportModal } from '@/hooks';
import * as Type from '@/common/interface';
import {
useQuestionSearch,
changeQuestionStatus,
deleteQuestion,
} from '@answer/api';
import * as Type from '@answer/common/interface';
} from '@/services';
import '../index.scss';
@ -31,9 +31,10 @@ const questionFilterItems: Type.AdminContentsFilterBy[] = [
const PAGE_SIZE = 20;
const Questions: FC = () => {
const [urlSearchParams] = useSearchParams();
const [urlSearchParams, setUrlSearchParams] = useSearchParams();
const curFilter = urlSearchParams.get('status') || questionFilterItems[0];
const curPage = Number(urlSearchParams.get('page')) || 1;
const curQuery = urlSearchParams.get('query') || '';
const { t } = useTranslation('translation', { keyPrefix: 'admin.questions' });
const {
@ -44,6 +45,7 @@ const Questions: FC = () => {
page_size: PAGE_SIZE,
page: curPage,
status: curFilter as Type.AdminContentsFilterBy,
query: curQuery,
});
const count = listData?.count || 0;
@ -96,6 +98,11 @@ const Questions: FC = () => {
});
};
const handleFilter = (e) => {
urlSearchParams.set('query', e.target.value);
urlSearchParams.delete('page');
setUrlSearchParams(urlSearchParams);
};
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
@ -108,10 +115,11 @@ const Questions: FC = () => {
/>
<Form.Control
value={curQuery}
size="sm"
type="input"
placeholder="Filter by title"
className="d-none"
placeholder={t('filter.placeholder')}
onChange={handleFilter}
style={{ width: '12.25rem' }}
/>
</div>
@ -147,12 +155,11 @@ const Questions: FC = () => {
</td>
<td>{li.vote_count}</td>
<td>
<a
href={`/questions/${li.id}`}
target="_blank"
<Link
to={`/admin/answers?questionId=${li.id}`}
rel="noreferrer">
{li.answer_count}
</a>
</Link>
</td>
<td>
<Stack>

View File

@ -2,10 +2,9 @@ import React, { FC, useEffect, useState } from 'react';
import { Form, Button, Stack } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import type * as Type from '@answer/common/interface';
import { useToast } from '@answer/hooks';
import { useSmtpSetting, updateSmtpSetting } from '@answer/api';
import type * as Type from '@/common/interface';
import { useToast } from '@/hooks';
import { useSmtpSetting, updateSmtpSetting } from '@/services';
import pattern from '@/common/pattern';
const Smtp: FC = () => {

View File

@ -1,18 +1,18 @@
import { FC, useState } from 'react';
import { FC } from 'react';
import { Button, Form, Table, Badge } from 'react-bootstrap';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQueryUsers } from '@answer/api';
import {
Pagination,
FormatTime,
BaseUserCard,
Empty,
QueryGroup,
} from '@answer/components';
import * as Type from '@answer/common/interface';
import { useChangeModal } from '@answer/hooks';
} from '@/components';
import * as Type from '@/common/interface';
import { useChangeModal } from '@/hooks';
import { useQueryUsers } from '@/services';
import '../index.scss';
@ -33,11 +33,11 @@ const bgMap = {
const PAGE_SIZE = 10;
const Users: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'admin.users' });
const [userName, setUserName] = useState('');
const [urlSearchParams] = useSearchParams();
const [urlSearchParams, setUrlSearchParams] = useSearchParams();
const curFilter = urlSearchParams.get('filter') || UserFilterKeys[0];
const curPage = Number(urlSearchParams.get('page') || '1');
const curQuery = urlSearchParams.get('query') || '';
const {
data,
isLoading,
@ -45,7 +45,7 @@ const Users: FC = () => {
} = useQueryUsers({
page: curPage,
page_size: PAGE_SIZE,
...(userName ? { username: userName } : {}),
query: curQuery,
...(curFilter === 'all' ? {} : { status: curFilter }),
});
const changeModal = useChangeModal({
@ -59,6 +59,11 @@ const Users: FC = () => {
});
};
const handleFilter = (e) => {
urlSearchParams.set('query', e.target.value);
urlSearchParams.delete('page');
setUrlSearchParams(urlSearchParams);
};
return (
<>
<h3 className="mb-4">{t('title')}</h3>
@ -71,11 +76,10 @@ const Users: FC = () => {
/>
<Form.Control
className="d-none"
size="sm"
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="Filter by name"
value={curQuery}
onChange={handleFilter}
placeholder={t('filter.placeholder')}
style={{ width: '12.25rem' }}
/>
</div>

View File

@ -3,8 +3,8 @@ import { Container, Row, Col } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom';
import { AccordionNav, PageTitle } from '@answer/components';
import { ADMIN_NAV_MENUS } from '@answer/common/constants';
import { AccordionNav, PageTitle } from '@/components';
import { ADMIN_NAV_MENUS } from '@/common/constants';
import './index.scss';

View File

@ -0,0 +1,33 @@
import { FC } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation, Trans } from 'react-i18next';
import Progress from '../Progress';
interface Props {
visible: boolean;
}
const Index: FC<Props> = ({ visible }) => {
const { t } = useTranslation('translation', { keyPrefix: 'install' });
if (!visible) return null;
return (
<div>
<h5>{t('ready_title')}</h5>
<p>
<Trans i18nKey="install.ready_description">
If you ever feel like changing more settings, visit
<a href="/">admin section</a>; find it in the site menu.
</Trans>
</p>
<p>{t('good_luck')}</p>
<div className="d-flex align-items-center justify-content-between">
<Progress step={5} />
<Button>{t('done')}</Button>
</div>
</div>
);
};
export default Index;

View File

@ -0,0 +1,68 @@
import { FC, useEffect, useState } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import type { LangsType, FormValue, FormDataType } from '@/common/interface';
import Progress from '../Progress';
import { languages } from '@/services';
interface Props {
data: FormValue;
changeCallback: (value: FormDataType) => void;
nextCallback: () => void;
visible: boolean;
}
const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => {
const { t } = useTranslation('translation', { keyPrefix: 'install' });
const [langs, setLangs] = useState<LangsType[]>();
const getLangs = async () => {
const res: LangsType[] = await languages();
setLangs(res);
};
const handleSubmit = () => {
nextCallback();
};
useEffect(() => {
getLangs();
}, []);
if (!visible) return null;
return (
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="lang" className="mb-3">
<Form.Label>{t('lang.label')}</Form.Label>
<Form.Select
value={data.value}
isInvalid={data.isInvalid}
onChange={(e) => {
changeCallback({
lang: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}>
{langs?.map((item) => {
return (
<option value={item.value} key={item.value}>
{item.label}
</option>
);
})}
</Form.Select>
</Form.Group>
<div className="d-flex align-items-center justify-content-between">
<Progress step={1} />
<Button type="submit">{t('next')}</Button>
</div>
</Form>
);
};
export default Index;

View File

@ -0,0 +1,207 @@
import { FC, FormEvent } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import type { FormDataType } from '@/common/interface';
import Progress from '../Progress';
interface Props {
data: FormDataType;
changeCallback: (value: FormDataType) => void;
nextCallback: () => void;
visible: boolean;
}
const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => {
const { t } = useTranslation('translation', { keyPrefix: 'install' });
const checkValidated = (): boolean => {
let bol = true;
const {
site_name,
contact_email,
admin_name,
admin_password,
admin_email,
} = data;
if (!site_name.value) {
bol = false;
data.site_name = {
value: '',
isInvalid: true,
errorMsg: t('site_name.msg'),
};
}
if (!contact_email.value) {
bol = false;
data.contact_email = {
value: '',
isInvalid: true,
errorMsg: t('contact_email.msg'),
};
}
if (!admin_name.value) {
bol = false;
data.admin_name = {
value: '',
isInvalid: true,
errorMsg: t('admin_name.msg'),
};
}
if (!admin_password.value) {
bol = false;
data.admin_password = {
value: '',
isInvalid: true,
errorMsg: t('admin_password.msg'),
};
}
if (!admin_email.value) {
bol = false;
data.admin_email = {
value: '',
isInvalid: true,
errorMsg: t('admin_email.msg'),
};
}
changeCallback({
...data,
});
return bol;
};
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
event.stopPropagation();
if (!checkValidated()) {
return;
}
nextCallback();
};
if (!visible) return null;
return (
<Form noValidate onSubmit={handleSubmit}>
<h5>{t('site_information')}</h5>
<Form.Group controlId="site_name" className="mb-3">
<Form.Label>{t('site_name.label')}</Form.Label>
<Form.Control
required
value={data.site_name.value}
isInvalid={data.site_name.isInvalid}
onChange={(e) => {
changeCallback({
site_name: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Control.Feedback type="invalid">
{data.site_name.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="contact_email" className="mb-3">
<Form.Label>{t('contact_email.label')}</Form.Label>
<Form.Control
required
value={data.contact_email.value}
isInvalid={data.contact_email.isInvalid}
onChange={(e) => {
changeCallback({
contact_email: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Text>{t('contact_email.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{data.contact_email.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<h5>{t('admin_account')}</h5>
<Form.Group controlId="admin_name" className="mb-3">
<Form.Label>{t('admin_name.label')}</Form.Label>
<Form.Control
required
value={data.admin_name.value}
isInvalid={data.admin_name.isInvalid}
onChange={(e) => {
changeCallback({
admin_name: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Control.Feedback type="invalid">
{data.admin_name.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="admin_password" className="mb-3">
<Form.Label>{t('admin_password.label')}</Form.Label>
<Form.Control
required
value={data.admin_password.value}
isInvalid={data.admin_password.isInvalid}
onChange={(e) => {
changeCallback({
admin_password: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Text>{t('admin_password.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{data.admin_password.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="admin_email" className="mb-3">
<Form.Label>{t('admin_email.label')}</Form.Label>
<Form.Control
required
value={data.admin_email.value}
isInvalid={data.admin_email.isInvalid}
onChange={(e) => {
changeCallback({
admin_email: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Text>{t('admin_email.text')}</Form.Text>
<Form.Control.Feedback type="invalid">
{data.admin_email.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<div className="d-flex align-items-center justify-content-between">
<Progress step={4} />
<Button type="submit">{t('next')}</Button>
</div>
</Form>
);
};
export default Index;

View File

@ -0,0 +1,22 @@
import { FC, memo } from 'react';
import { ProgressBar } from 'react-bootstrap';
interface IProps {
step: number;
}
const Index: FC<IProps> = ({ step }) => {
return (
<div className="d-flex align-items-center fs-14 text-secondary">
<ProgressBar
now={(step / 5) * 100}
variant="success"
style={{ width: '200px' }}
className="me-2"
/>
<span>{step}/5</span>
</div>
);
};
export default memo(Index);

View File

@ -0,0 +1,246 @@
import { FC, FormEvent } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import Progress from '../Progress';
import type { FormDataType } from '@/common/interface';
interface Props {
data: FormDataType;
changeCallback: (value: FormDataType) => void;
nextCallback: () => void;
visible: boolean;
}
const sqlData = [
{
value: 'mysql',
label: 'MariaDB/MySQL',
},
{
value: 'sqlite3',
label: 'SQLite',
},
{
value: 'postgres',
label: 'PostgreSQL',
},
];
const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => {
const { t } = useTranslation('translation', { keyPrefix: 'install' });
const checkValidated = (): boolean => {
let bol = true;
const { db_type, db_username, db_password, db_host, db_name, db_file } =
data;
if (db_type.value !== 'sqllite3') {
if (!db_username.value) {
bol = false;
data.db_username = {
value: '',
isInvalid: true,
errorMsg: t('db_username.msg'),
};
}
if (!db_password.value) {
bol = false;
data.db_password = {
value: '',
isInvalid: true,
errorMsg: t('db_password.msg'),
};
}
if (!db_host.value) {
bol = false;
data.db_host = {
value: '',
isInvalid: true,
errorMsg: t('db_host.msg'),
};
}
if (!db_name.value) {
bol = false;
data.db_name = {
value: '',
isInvalid: true,
errorMsg: t('db_name.msg'),
};
}
} else if (!db_file.value) {
bol = false;
data.db_file = {
value: '',
isInvalid: true,
errorMsg: t('db_file.msg'),
};
}
changeCallback({
...data,
});
return bol;
};
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
event.stopPropagation();
if (!checkValidated()) {
return;
}
nextCallback();
};
if (!visible) return null;
return (
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="database_engine" className="mb-3">
<Form.Label>{t('db_type.label')}</Form.Label>
<Form.Select
value={data.db_type.value}
isInvalid={data.db_type.isInvalid}
onChange={(e) => {
changeCallback({
db_type: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}>
{sqlData.map((item) => {
return (
<option key={item.value} value={item.value}>
{item.label}
</option>
);
})}
</Form.Select>
</Form.Group>
{data.db_type.value !== 'sqlite3' ? (
<>
<Form.Group controlId="username" className="mb-3">
<Form.Label>{t('db_username.label')}</Form.Label>
<Form.Control
required
placeholder={t('db_username.placeholder')}
value={data.db_username.value}
isInvalid={data.db_username.isInvalid}
onChange={(e) => {
changeCallback({
db_username: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Control.Feedback type="invalid">
{data.db_username.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="db_password" className="mb-3">
<Form.Label>{t('db_password.label')}</Form.Label>
<Form.Control
required
placeholder={t('db_password.placeholder')}
value={data.db_password.value}
isInvalid={data.db_password.isInvalid}
onChange={(e) => {
changeCallback({
db_password: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Control.Feedback type="invalid">
{data.db_password.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="db_host" className="mb-3">
<Form.Label>{t('db_host.label')}</Form.Label>
<Form.Control
required
placeholder={t('db_host.placeholder')}
value={data.db_host.value}
isInvalid={data.db_host.isInvalid}
onChange={(e) => {
changeCallback({
db_host: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Control.Feedback type="invalid">
{data.db_host.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="name" className="mb-3">
<Form.Label>{t('db_name.label')}</Form.Label>
<Form.Control
required
placeholder={t('db_name.placeholder')}
value={data.db_name.value}
isInvalid={data.db_name.isInvalid}
onChange={(e) => {
changeCallback({
db_name: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Control.Feedback type="invalid">
{data.db_name.errorMsg}
</Form.Control.Feedback>
</Form.Group>
</>
) : (
<Form.Group controlId="file" className="mb-3">
<Form.Label>{t('db_file.label')}</Form.Label>
<Form.Control
required
placeholder={t('db_file.placeholder')}
value={data.db_file.value}
isInvalid={data.db_file.isInvalid}
onChange={(e) => {
changeCallback({
db_file: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
});
}}
/>
<Form.Control.Feedback type="invalid">
{data.db_file.errorMsg}
</Form.Control.Feedback>
</Form.Group>
)}
<div className="d-flex align-items-center justify-content-between">
<Progress step={2} />
<Button type="submit">{t('next')}</Button>
</div>
</Form>
);
};
export default Index;

View File

@ -0,0 +1,40 @@
import { FC } from 'react';
import { Form, Button, FormGroup } from 'react-bootstrap';
import { useTranslation, Trans } from 'react-i18next';
import Progress from '../Progress';
interface Props {
visible: boolean;
nextCallback: () => void;
}
const Index: FC<Props> = ({ visible, nextCallback }) => {
const { t } = useTranslation('translation', { keyPrefix: 'install' });
if (!visible) return null;
return (
<div>
<h5>{t('config_yaml.title')}</h5>
<div className="mb-3">{t('config_yaml.label')}</div>
<div className="fmt">
<p>
<Trans
i18nKey="install.config_yaml.description"
components={{ 1: <code /> }}
/>
</p>
</div>
<FormGroup className="mb-3">
<Form.Control type="text" as="textarea" rows={5} className="fs-14" />
</FormGroup>
<div className="mb-3">{t('config_yaml.info')}</div>
<div className="d-flex align-items-center justify-content-between">
<Progress step={3} />
<Button onClick={nextCallback}>{t('next')}</Button>
</div>
</div>
);
};
export default Index;

View File

@ -0,0 +1,7 @@
import FirstStep from './FirstStep';
import SecondStep from './SecondStep';
import ThirdStep from './ThirdStep';
import FourthStep from './FourthStep';
import Fifth from './FifthStep';
export { FirstStep, SecondStep, ThirdStep, FourthStep, Fifth };

View File

@ -0,0 +1,182 @@
import { FC, useState, useEffect } from 'react';
import { Container, Row, Col, Card, Alert } from 'react-bootstrap';
import { useTranslation, Trans } from 'react-i18next';
import type { FormDataType } from '@/common/interface';
import { Storage } from '@/utils';
import { PageTitle } from '@/components';
import {
FirstStep,
SecondStep,
ThirdStep,
FourthStep,
Fifth,
} from './components';
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'install' });
const [step, setStep] = useState(1);
const [showError] = useState(false);
const [formData, setFormData] = useState<FormDataType>({
lang: {
value: '',
isInvalid: false,
errorMsg: '',
},
db_type: {
value: '',
isInvalid: false,
errorMsg: '',
},
db_username: {
value: '',
isInvalid: false,
errorMsg: '',
},
db_password: {
value: '',
isInvalid: false,
errorMsg: '',
},
db_host: {
value: '',
isInvalid: false,
errorMsg: '',
},
db_name: {
value: '',
isInvalid: false,
errorMsg: '',
},
db_file: {
value: '',
isInvalid: false,
errorMsg: '',
},
site_name: {
value: '',
isInvalid: false,
errorMsg: '',
},
contact_email: {
value: '',
isInvalid: false,
errorMsg: '',
},
admin_name: {
value: '',
isInvalid: false,
errorMsg: '',
},
admin_password: {
value: '',
isInvalid: false,
errorMsg: '',
},
admin_email: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
const handleChange = (params: FormDataType) => {
console.log(params);
setFormData({ ...formData, ...params });
};
const handleStep = () => {
setStep((pre) => pre + 1);
};
// const handleSubmit = () => {
// const params = {
// lang: formData.lang.value,
// db_type: formData.db_type.value,
// db_username: formData.db_username.value,
// db_password: formData.db_password.value,
// db_host: formData.db_host.value,
// db_name: formData.db_name.value,
// db_file: formData.db_file.value,
// site_name: formData.site_name.value,
// contact_email: formData.contact_email.value,
// admin_name: formData.admin_name.value,
// admin_password: formData.admin_password.value,
// admin_email: formData.admin_email.value,
// };
// console.log(params);
// };
useEffect(() => {
console.log('step===', Storage.get('INSTALL_STEP'));
}, []);
return (
<div className="page-wrap2">
<PageTitle title={t('install', { keyPrefix: 'page_title' })} />
<Container style={{ paddingTop: '74px' }}>
<Row className="justify-content-center">
<Col lg={6}>
<h2 className="mb-4 text-center">{t('title')}</h2>
<Card>
<Card.Body>
{showError && <Alert variant="danger"> show error msg </Alert>}
<FirstStep
visible={step === 1}
data={formData.lang}
changeCallback={handleChange}
nextCallback={handleStep}
/>
<SecondStep
visible={step === 2}
data={formData}
changeCallback={handleChange}
nextCallback={handleStep}
/>
<ThirdStep visible={step === 3} nextCallback={handleStep} />
<FourthStep
visible={step === 4}
data={formData}
changeCallback={handleChange}
nextCallback={handleStep}
/>
<Fifth visible={step === 5} />
{step === 6 && (
<div>
<h5>{t('warning')}</h5>
<p>
<Trans i18nKey="install.warning_description">
The file <code>config.yaml</code> already exists. If you
need to reset any of the configuration items in this
file, please delete it first. You may try{' '}
<a href="/">installing now</a>.
</Trans>
</p>
</div>
)}
{step === 7 && (
<div>
<h5>{t('installed')}</h5>
<p>{t('installed_description')}</p>
</div>
)}
</Card.Body>
</Card>
</Col>
</Row>
</Container>
</div>
);
};
export default Index;

View File

@ -1,60 +1,42 @@
import { FC, useEffect } from 'react';
import { FC, useEffect, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom';
import { Helmet, HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr';
import {
userInfoStore,
siteInfoStore,
interfaceStore,
toastStore,
} from '@answer/stores';
import { Header, AdminHeader, Footer, Toast } from '@answer/components';
import { useSiteSettings, useCheckUserStatus } from '@answer/api';
import { siteInfoStore, interfaceStore, toastStore } from '@/stores';
import { Header, AdminHeader, Footer, Toast } from '@/components';
import { useSiteSettings } from '@/services';
import Storage from '@/utils/storage';
import { CURRENT_LANG_STORAGE_KEY } from '@/common/constants';
let isMounted = false;
const Layout: FC = () => {
const { siteInfo, update: siteStoreUpdate } = siteInfoStore();
const { update: interfaceStoreUpdate } = interfaceStore();
const { data: siteSettings } = useSiteSettings();
const { data: userStatus } = useCheckUserStatus();
useEffect(() => {
if (siteSettings) {
siteStoreUpdate(siteSettings.general);
interfaceStoreUpdate(siteSettings.interface);
}
}, [siteSettings]);
const updateUser = userInfoStore((state) => state.update);
const { msg: toastMsg, variant, clear: toastClear } = toastStore();
const { i18n } = useTranslation();
const closeToast = () => {
toastClear();
};
useEffect(() => {
if (siteSettings) {
siteStoreUpdate(siteSettings.general);
interfaceStoreUpdate(siteSettings.interface);
}
}, [siteSettings]);
if (!isMounted) {
isMounted = true;
const lang = Storage.get('LANG');
const user = Storage.get('userInfo');
if (user) {
updateUser(user);
}
const lang = Storage.get(CURRENT_LANG_STORAGE_KEY);
if (lang) {
i18n.changeLanguage(lang);
}
}
if (userStatus?.status) {
const user = Storage.get('userInfo');
if (userStatus.status !== user.status) {
user.status = userStatus?.status;
updateUser(user);
}
}
return (
<HelmetProvider>
<Helmet>
@ -76,4 +58,4 @@ const Layout: FC = () => {
);
};
export default Layout;
export default memo(Layout);

View File

@ -0,0 +1,27 @@
import { Container } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { PageTitle } from '@/components';
const Index = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'page_maintenance',
});
return (
<div className="page-wrap2">
<Container
className="d-flex flex-column justify-content-center align-items-center"
style={{ minHeight: '100vh' }}>
<PageTitle title={t('maintenance', { keyPrefix: 'page_title' })} />
<div
className="mb-4 text-secondary"
style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=_=)
</div>
<div className="text-center mb-4">{t('description')}</div>
</Container>
</div>
);
};
export default Index;

View File

@ -2,7 +2,7 @@ import { memo } from 'react';
import { Accordion, ListGroup } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Icon } from '@answer/components';
import { Icon } from '@/components';
import './index.scss';

View File

@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { Editor, EditorRef, TagSelector, PageTitle } from '@answer/components';
import { Editor, EditorRef, TagSelector, PageTitle } from '@/components';
import type * as Type from '@/common/interface';
import {
saveQuestion,
questionDetail,
@ -14,8 +15,7 @@ import {
useQueryRevisions,
postAnswer,
useQueryQuestionByTitle,
} from '@answer/api';
import type * as Type from '@answer/common/interface';
} from '@/services';
import SearchQuestion from './components/SearchQuestion';

View File

@ -10,10 +10,10 @@ import {
Comment,
FormatTime,
htmlRender,
} from '@answer/components';
import { acceptanceAnswer } from '@answer/api';
import { scrollTop } from '@answer/utils';
import { AnswerItem } from '@answer/common/interface';
} from '@/components';
import { scrollTop } from '@/utils';
import { AnswerItem } from '@/common/interface';
import { acceptanceAnswer } from '@/services';
interface Props {
data: AnswerItem;

View File

@ -1,7 +1,7 @@
import { memo, FC } from 'react';
import { useTranslation } from 'react-i18next';
import { QueryGroup } from '@answer/components';
import { QueryGroup } from '@/components';
interface Props {
count: number;

View File

@ -11,9 +11,9 @@ import {
Comment,
FormatTime,
htmlRender,
} from '@answer/components';
import { formatCount } from '@answer/utils';
import { following } from '@answer/api';
} from '@/components';
import { formatCount } from '@/utils';
import { following } from '@/services';
interface Props {
data: any;

View File

@ -3,16 +3,15 @@ import { Card, ListGroup } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useSimilarQuestion } from '@answer/api';
import { Icon } from '@answer/components';
import { userInfoStore } from '@/stores';
import { Icon } from '@/components';
import { useSimilarQuestion } from '@/services';
import { loggedUserInfoStore } from '@/stores';
interface Props {
id: string;
}
const Index: FC<Props> = ({ id }) => {
const { user } = userInfoStore();
const { user } = loggedUserInfoStore();
const { t } = useTranslation('translation', {
keyPrefix: 'related_question',
});

View File

@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next';
import { marked } from 'marked';
import classNames from 'classnames';
import { Editor, Modal } from '@answer/components';
import { postAnswer } from '@answer/api';
import { FormDataType } from '@answer/common/interface';
import { Editor, Modal } from '@/components';
import { FormDataType } from '@/common/interface';
import { postAnswer } from '@/services';
interface Props {
visible?: boolean;

View File

@ -2,16 +2,16 @@ import { useEffect, useState } from 'react';
import { Container, Row, Col } from 'react-bootstrap';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { questionDetail, getAnswers } from '@answer/api';
import { Pagination, PageTitle } from '@answer/components';
import { userInfoStore } from '@answer/stores';
import { scrollTop } from '@answer/utils';
import { usePageUsers } from '@answer/hooks';
import { Pagination, PageTitle } from '@/components';
import { loggedUserInfoStore } from '@/stores';
import { scrollTop } from '@/utils';
import { usePageUsers } from '@/hooks';
import type {
ListResult,
QuestionDetailRes,
AnswerItem,
} from '@answer/common/interface';
} from '@/common/interface';
import { questionDetail, getAnswers } from '@/services';
import {
Question,
@ -37,7 +37,7 @@ const Index = () => {
list: [],
});
const { setUsers } = usePageUsers();
const userInfo = userInfoStore((state) => state.user);
const userInfo = loggedUserInfoStore((state) => state.user);
const isAuthor = userInfo?.username === question?.user_info?.username;
const requestAnswers = async () => {
const res = await getAnswers({

View File

@ -6,13 +6,13 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import classNames from 'classnames';
import { Editor, EditorRef, Icon, PageTitle } from '@answer/components';
import { Editor, EditorRef, Icon, PageTitle } from '@/components';
import type * as Type from '@/common/interface';
import {
useQueryAnswerInfo,
modifyAnswer,
useQueryRevisions,
} from '@answer/api';
import type * as Type from '@answer/common/interface';
} from '@/services';
import './index.scss';

View File

@ -3,8 +3,7 @@ import { Container, Row, Col } from 'react-bootstrap';
import { useMatch } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageTitle, FollowingTags } from '@answer/components';
import { PageTitle, FollowingTags } from '@/components';
import QuestionList from '@/components/QuestionList';
import HotQuestions from '@/components/HotQuestions';
import { siteInfoStore } from '@/stores';

View File

@ -3,8 +3,8 @@ import { useSearchParams, Link } from 'react-router-dom';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { following } from '@answer/api';
import { isLogin } from '@answer/utils';
import { following } from '@/services';
import { tryNormalLogged } from '@/utils/guard';
interface Props {
data;
@ -20,7 +20,7 @@ const Index: FC<Props> = ({ data }) => {
const [followed, setFollowed] = useState(data?.is_follower);
const follow = () => {
if (!isLogin(true)) {
if (!tryNormalLogged(true)) {
return;
}
following({

View File

@ -2,7 +2,7 @@ import { FC, memo } from 'react';
import { ListGroupItem } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { QueryGroup } from '@answer/components';
import { QueryGroup } from '@/components';
const sortBtns = ['relevance', 'newest', 'active', 'score'];

View File

@ -2,8 +2,8 @@ import { memo, FC } from 'react';
import { ListGroupItem, Badge } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Icon, Tag, FormatTime, BaseUserCard } from '@answer/components';
import type { SearchResItem } from '@answer/common/interface';
import { Icon, Tag, FormatTime, BaseUserCard } from '@/components';
import type { SearchResItem } from '@/common/interface';
interface Props {
data: SearchResItem;

View File

@ -3,8 +3,8 @@ import { Container, Row, Col, ListGroup } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { Pagination, PageTitle } from '@answer/components';
import { useSearch } from '@answer/api';
import { Pagination, PageTitle } from '@/components';
import { useSearch } from '@/services';
import { Head, SearchHead, SearchItem, Tips, Empty } from './components';

Some files were not shown because too many files have changed in this diff Show More