diff --git a/.gitignore b/.gitignore index 5f313528..84af0c17 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ /.fleet /.vscode/*.log /cmd/answer/*.sh -/cmd/answer/upfiles/* +/cmd/answer/uploads/* /cmd/logs /configs/config-dev.yaml /go.work* diff --git a/Dockerfile b/Dockerfile index 8b55d58f..ac06839a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ RUN apk --no-cache add build-base git \ && make clean build \ && cp answer /usr/bin/answer -RUN mkdir -p /data/upfiles && chmod 777 /data/upfiles \ +RUN mkdir -p /data/uploads && chmod 777 /data/uploads \ && mkdir -p /data/i18n && cp -r i18n/*.yaml /data/i18n # stage3 copy the binary and resource files into fresh container diff --git a/INSTALL.md b/INSTALL.md index 0d3969d2..7bc307b5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -91,7 +91,7 @@ swaggerui: service_config: secret_key: "answer" #encryption key web_host: "http://127.0.0.1" #Page access using domain name address - upload_path: "./upfiles" #upload directory + upload_path: "./uploads" #upload directory ``` ## Compile the image @@ -100,4 +100,4 @@ If you have modified the source files and want to repackage the image, you can u docker build -t answer:v1.0.0 . ``` ## common problem - 1. The project cannot be started: the main program startup depends on proper configuraiton of the configuration file, `config.yaml`, as well as the internationalization translation directory (`i18n`), and the upload file storage directory (`upfiles`). Ensure that the configuration file is loaded when the project starts, such as when using `answer run -c config.yaml` and that the `config.yaml` correctly specifies the i18n and upfiles directories. + 1. The project cannot be started: the main program startup depends on proper configuraiton of the configuration file, `config.yaml`, as well as the internationalization translation directory (`i18n`), and the upload file storage directory (`uploads`). Ensure that the configuration file is loaded when the project starts, such as when using `answer run -c config.yaml` and that the `config.yaml` correctly specifies the i18n and uploads directories. diff --git a/INSTALL_CN.md b/INSTALL_CN.md index 300851a2..bc121771 100644 --- a/INSTALL_CN.md +++ b/INSTALL_CN.md @@ -1,80 +1,54 @@ # Answer 安装指引 -安装 Answer 之前,您需要先安装基本环境。 - - 数据库 - - [MySQL](http://dev.mysql.com):版本 >= 5.7 - -然后,您可以通过以下几种方式来安装 Answer: - - - 采用 Docker 部署 - - 二进制安装 - - 源码安装 - -## 使用 Docker-compose 安装 Answer +## 使用 docker 安装 +### 步骤 1: 使用 docker 命令启动项目 ```bash -$ mkdir answer && cd answer -$ wget https://raw.githubusercontent.com/answerdev/answer/main/docker-compose.yaml -$ docker-compose up +docker run -d -p 9080:80 -v answer-data:/data --name answer answerdev/answer:latest +``` +### 步骤 2: 访问安装路径进行项目安装 +[http://127.0.0.1:9080/install](http://127.0.0.1:9080/install) + +选择语言后点击下一步选择合适的数据库,如果当前只是想体验,建议直接选择 sqlite 作为数据库,如下图所示 + +![install-database](docs/img/install-database.png) + +然后点击下一步会进行配置文件创建等操作,点击下一步输入网站基本信息和管理员信息,如下图所示 + +![install-site-info](docs/img/install-site-info.png) + +点击下一步即可安装完成 + +### 步骤 3:安装完成后访问项目路径开始使用 +[http://127.0.0.1:9080/](http://127.0.0.1:9080/) + +使用刚才创建的管理员用户名密码即可登录。 + +## 使用 docker-compose 安装 +### 步骤 1: 使用 docker-compose 命令启动项目 +```bash +mkdir answer && cd answer +wget https://raw.githubusercontent.com/answerdev/answer/main/docker-compose.yaml +docker-compose up ``` -启动完成后使用浏览器访问 [http://127.0.0.1:9080/](http://127.0.0.1:9080/). +### 步骤 2: 访问安装路径进行项目安装 +[http://127.0.0.1:9080/install](http://127.0.0.1:9080/install) -你可以使用默认的用户名:( **`admin@admin.com`** ) 和密码:( **`admin`** ) 进行登录. +具体配置与 docker 使用时相同 -## 使用Docker 安装 Answer -可以从 Docker Hub 或者 GitHub Container registry 下载最新的 tags 镜像 +### 步骤 3:安装完成后访问项目路径开始使用 +[http://127.0.0.1:9080/](http://127.0.0.1:9080/) -### 用法 -将配置和存储目录挂在到镜像之外 volume (/var/data -> /data),你可以修改外部挂载地址 +## 使用 二进制 安装 +### 步骤 1: 下载二进制文件 +[https://github.com/answerdev/answer/releases](https://github.com/answerdev/answer/releases) +请下载您当下系统所需要的对应版本 -``` -# 将镜像从 docker hub 拉到本地 -$ docker pull answerdev/answer:latest - -# 创建一个挂载目录 -$ mkdir -p /var/data - -# 先运行一遍镜像 -$ docker run --name=answer -p 9080:80 -v /var/data:/data answerdev/answer - -# 第一次启动后会在/var/data 目录下生成配置文件 -# /var/data/conf/config.yaml -# 需要修改配置文件中的Mysql 数据库地址 -vim /var/data/conf/config.yaml - -# 修改数据库连接 connection: [username]:[password]@tcp([host]:[port])/[DbName] -... - -# 配置好配置文件后可以再次启动镜像即可启动服务 -$ docker start answer -``` - -## 使用二进制 安装 Answer -可以使用编译完成的各个平台的二进制文件运行 Answer 项目 -### 用法 -从 GitHub 最新版本的tag中下载对应平台的二进制文件压缩包 - - 1. 解压压缩包 - 2. 使用命令 cd 进入到刚刚创建的目录 - 3. 执行命令 ./answer init - 4. Answer 会在当前目录生成 ./data 目录 - 5. 进入 data 目录修改 config.yaml 文件 - 6. 将数据库连接地址修改为你的数据库连接地址 - - connection: [username]:[password]@tcp([host]:[port])/[DbName] - 7. 退出 data 目录,执行 ./answer run -c ./data/conf/config.yaml - -## 当前支持的命令 -用法: answer [command] - -- help: 帮助 -- init: 初始化环境 -- run: 启动 -- check: 环境依赖检查 -- dump: 备份数据 - -## 配置文件 config.yaml 参数说明 +### 步骤 2: 使用命令行安装 +> 以下命令中 -C 指定的是 answer 所需的数据目录,您可以根据实际需要进行修改 +```bash +./answer init -C ./answer-data/ ``` server: http: diff --git a/cmd/answer/command.go b/cmd/answer/command.go index d0efb0ab..5d026f77 100644 --- a/cmd/answer/command.go +++ b/cmd/answer/command.go @@ -4,14 +4,14 @@ import ( "fmt" "os" + "github.com/answerdev/answer/internal/base/conf" "github.com/answerdev/answer/internal/cli" + "github.com/answerdev/answer/internal/install" "github.com/answerdev/answer/internal/migrations" "github.com/spf13/cobra" ) var ( - // configFilePath is the config file path - configFilePath string // dataDirPath save all answer application data in this directory. like config file, upload file... dataDirPath string // dumpDataPath dump data path @@ -21,9 +21,7 @@ var ( func init() { rootCmd.Version = fmt.Sprintf("%s\nrevision: %s\nbuild time: %s", Version, Revision, Time) - initCmd.Flags().StringVarP(&dataDirPath, "data-path", "C", "/data/", "data path, eg: -C ./data/") - - rootCmd.PersistentFlags().StringVarP(&configFilePath, "config", "c", "", "config path, eg: -c config.yaml") + rootCmd.PersistentFlags().StringVarP(&dataDirPath, "data-path", "C", "/data/", "data path, eg: -C ./data/") dumpCmd.Flags().StringVarP(&dumpDataPath, "path", "p", "./", "dump data path, eg: -p ./dump/data/") @@ -49,6 +47,9 @@ To run answer, use: Short: "Run the application", Long: `Run the application`, Run: func(_ *cobra.Command, _ []string) { + cli.FormatAllPath(dataDirPath) + fmt.Println("config file path: ", cli.GetConfigFilePath()) + fmt.Println("Answer is string..........................") runApp() }, } @@ -59,18 +60,27 @@ To run answer, use: Short: "init answer application", Long: `init answer application`, Run: func(_ *cobra.Command, _ []string) { + // check config file and database. if config file exists and database is already created, init done cli.InstallAllInitialEnvironment(dataDirPath) - c, err := readConfig() - if err != nil { - fmt.Println("read config failed: ", err.Error()) - return + + configFileExist := cli.CheckConfigFile(cli.GetConfigFilePath()) + if configFileExist { + fmt.Println("config file exists, try to read the config...") + c, err := conf.ReadConfig(cli.GetConfigFilePath()) + if err != nil { + fmt.Println("read config failed: ", err.Error()) + return + } + + fmt.Println("config file read successfully, try to connect database...") + if cli.CheckDBTableExist(c.Data.Database) { + fmt.Println("connect to database successfully and table already exists, do nothing.") + return + } } - fmt.Println("read config successfully") - if err := migrations.InitDB(c.Data.Database); err != nil { - fmt.Println("init database error: ", err.Error()) - return - } - fmt.Println("init database successfully") + + // start installation server to install + install.Run(cli.GetConfigFilePath()) }, } @@ -80,7 +90,8 @@ To run answer, use: Short: "upgrade Answer version", Long: `upgrade Answer version`, Run: func(_ *cobra.Command, _ []string) { - c, err := readConfig() + cli.FormatAllPath(dataDirPath) + c, err := conf.ReadConfig(cli.GetConfigFilePath()) if err != nil { fmt.Println("read config failed: ", err.Error()) return @@ -100,7 +111,8 @@ To run answer, use: Long: `back up data`, Run: func(_ *cobra.Command, _ []string) { fmt.Println("Answer is backing up data") - c, err := readConfig() + cli.FormatAllPath(dataDirPath) + c, err := conf.ReadConfig(cli.GetConfigFilePath()) if err != nil { fmt.Println("read config failed: ", err.Error()) return @@ -120,8 +132,9 @@ To run answer, use: Short: "checking the required environment", Long: `Check if the current environment meets the startup requirements`, Run: func(_ *cobra.Command, _ []string) { + cli.FormatAllPath(dataDirPath) fmt.Println("Start checking the required environment...") - if cli.CheckConfigFile(configFilePath) { + if cli.CheckConfigFile(cli.GetConfigFilePath()) { fmt.Println("config file exists [✔]") } else { fmt.Println("config file not exists [x]") @@ -133,13 +146,13 @@ To run answer, use: fmt.Println("upload directory not exists [x]") } - c, err := readConfig() + c, err := conf.ReadConfig(cli.GetConfigFilePath()) if err != nil { fmt.Println("read config failed: ", err.Error()) return } - if cli.CheckDB(c.Data.Database) { + if cli.CheckDBConnection(c.Data.Database) { fmt.Println("db connection successfully [✔]") } else { fmt.Println("db connection failed [x]") diff --git a/cmd/answer/main.go b/cmd/answer/main.go index 08214244..0cc8ea74 100644 --- a/cmd/answer/main.go +++ b/cmd/answer/main.go @@ -2,13 +2,14 @@ package main import ( "os" - "path/filepath" + "time" "github.com/answerdev/answer/internal/base/conf" + "github.com/answerdev/answer/internal/base/constant" "github.com/answerdev/answer/internal/cli" + "github.com/answerdev/answer/internal/schema" "github.com/gin-gonic/gin" "github.com/segmentfault/pacman" - "github.com/segmentfault/pacman/contrib/conf/viper" "github.com/segmentfault/pacman/contrib/log/zap" "github.com/segmentfault/pacman/contrib/server/http" "github.com/segmentfault/pacman/log" @@ -40,8 +41,7 @@ func main() { func runApp() { log.SetLogger(zap.NewLogger( log.ParseLevel(logLevel), zap.WithName("answer"), zap.WithPath(logPath), zap.WithCallerFullPath())) - - c, err := readConfig() + c, err := conf.ReadConfig(cli.GetConfigFilePath()) if err != nil { panic(err) } @@ -50,27 +50,15 @@ func runApp() { if err != nil { panic(err) } + constant.Version = Version + schema.AppStartTime = time.Now() + defer cleanup() if err := app.Run(); err != nil { panic(err) } } -func readConfig() (c *conf.AllConfig, err error) { - if len(configFilePath) == 0 { - configFilePath = filepath.Join(cli.ConfigFilePath, cli.DefaultConfigFileName) - } - c = &conf.AllConfig{} - config, err := viper.NewWithPath(configFilePath) - if err != nil { - return nil, err - } - if err = config.Parse(&c); err != nil { - return nil, err - } - return c, nil -} - func newApplication(serverConf *conf.Server, server *gin.Engine) *pacman.Application { return pacman.NewApp( pacman.WithName(Name), diff --git a/cmd/answer/wire_gen.go b/cmd/answer/wire_gen.go index a965ed8b..7b7a6937 100644 --- a/cmd/answer/wire_gen.go +++ b/cmd/answer/wire_gen.go @@ -44,6 +44,7 @@ import ( auth2 "github.com/answerdev/answer/internal/service/auth" "github.com/answerdev/answer/internal/service/collection_common" comment2 "github.com/answerdev/answer/internal/service/comment" + "github.com/answerdev/answer/internal/service/dashboard" export2 "github.com/answerdev/answer/internal/service/export" "github.com/answerdev/answer/internal/service/follow" meta2 "github.com/answerdev/answer/internal/service/meta" @@ -58,6 +59,8 @@ import ( "github.com/answerdev/answer/internal/service/report_handle_backyard" "github.com/answerdev/answer/internal/service/revision_common" "github.com/answerdev/answer/internal/service/service_config" + "github.com/answerdev/answer/internal/service/siteinfo" + "github.com/answerdev/answer/internal/service/siteinfo_common" tag2 "github.com/answerdev/answer/internal/service/tag" "github.com/answerdev/answer/internal/service/tag_common" "github.com/answerdev/answer/internal/service/uploader" @@ -76,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 @@ -90,6 +92,9 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, cleanup() return nil, nil, err } + siteInfoRepo := site_info.NewSiteInfo(dataData) + siteInfoCommonService := siteinfo_common.NewSiteInfoCommonService(siteInfoRepo) + langController := controller.NewLangController(i18nTranslator, siteInfoCommonService) authRepo := auth.NewAuthRepo(dataData) authService := auth2.NewAuthService(authRepo) configRepo := config.NewConfigRepo(dataData) @@ -99,12 +104,11 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, 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) + userService := service.NewUserService(userRepo, userActiveActivityRepo, emailService, authService, serviceConf, siteInfoCommonService) captchaRepo := captcha.NewCaptchaRepo(dataData) captchaService := action.NewCaptchaService(captchaRepo) - uploaderService := uploader.NewUploaderService(serviceConf) + uploaderService := uploader.NewUploaderService(serviceConf, siteInfoCommonService) userController := controller.NewUserController(authService, userService, captchaService, emailService, uploaderService) commentRepo := comment.NewCommentRepo(dataData, uniqueIDRepo) commentCommonRepo := comment.NewCommentCommonRepo(dataData, uniqueIDRepo) @@ -148,7 +152,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, questionService := service.NewQuestionService(questionRepo, tagCommonService, questionCommon, userCommon, revisionService, metaService, collectionCommon, answerActivityService) questionController := controller.NewQuestionController(questionService, rankService) answerService := service.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo) - answerController := controller.NewAnswerController(answerService, rankService) + dashboardService := dashboard.NewDashboardService(questionRepo, answerRepo, commentCommonRepo, voteRepo, userRepo, reportRepo, configRepo, siteInfoCommonService, serviceConf, dataData) + answerController := controller.NewAnswerController(answerService, rankService, dashboardService) searchRepo := search_common.NewSearchRepo(dataData, uniqueIDRepo, userCommon) searchService := service.NewSearchService(searchRepo, tagRepo, userCommon, followRepo) searchController := controller.NewSearchController(searchService) @@ -166,14 +171,15 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, reasonService := reason2.NewReasonService(reasonRepo) reasonController := controller.NewReasonController(reasonService) themeController := controller_backyard.NewThemeController() - siteInfoService := service.NewSiteInfoService(siteInfoRepo, emailService) + siteInfoService := siteinfo.NewSiteInfoService(siteInfoRepo, emailService) siteInfoController := controller_backyard.NewSiteInfoController(siteInfoService) - siteinfoController := controller.NewSiteinfoController(siteInfoService) + siteinfoController := controller.NewSiteinfoController(siteInfoCommonService) notificationRepo := notification.NewNotificationRepo(dataData) notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService) notificationService := notification2.NewNotificationService(dataData, notificationRepo, notificationCommon) notificationController := controller.NewNotificationController(notificationService) - answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, controller_backyardReportController, userBackyardController, reasonController, themeController, siteInfoController, siteinfoController, notificationController) + dashboardController := controller.NewDashboardController(dashboardService) + answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, controller_backyardReportController, userBackyardController, reasonController, themeController, siteInfoController, siteinfoController, notificationController, dashboardController) swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter() authUserMiddleware := middleware.NewAuthUserMiddleware(authService) diff --git a/configs/config.yaml b/configs/config.yaml index 6830048f..8032deed 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -17,4 +17,4 @@ swaggerui: service_config: secret_key: "answer" web_host: "http://127.0.0.1:9080" - upload_path: "/data/upfiles" + upload_path: "/data/uploads" diff --git a/docs/docs.go b/docs/docs.go index f21f508c..eb097216 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -62,12 +62,6 @@ const docTemplate = `{ "description": "answer id or question title", "name": "query", "in": "query" - }, - { - "type": "string", - "description": "question id", - "name": "question_id", - "in": "query" } ], "responses": { @@ -119,6 +113,34 @@ const docTemplate = `{ } } }, + "/answer/admin/api/dashboard": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "DashboardInfo", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "DashboardInfo", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/admin/api/language/options": { "get": { "security": [ @@ -487,14 +509,14 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Get siteinfo general", + "description": "get site general information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Get siteinfo general", + "summary": "get site general information", "responses": { "200": { "description": "OK", @@ -522,14 +544,14 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Get siteinfo interface", + "description": "update site general information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Get siteinfo interface", + "summary": "update site general information", "parameters": [ { "description": "general", @@ -558,25 +580,14 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Get siteinfo interface", + "description": "get site interface", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Get siteinfo interface", - "parameters": [ - { - "description": "general", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/schema.AddCommentReq" - } - } - ], + "summary": "get site interface", "responses": { "200": { "description": "OK", @@ -604,14 +615,14 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Get siteinfo interface", + "description": "update site info interface", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Get siteinfo interface", + "summary": "update site info interface", "parameters": [ { "description": "general", @@ -2710,14 +2721,14 @@ const docTemplate = `{ }, "/answer/api/v1/siteinfo": { "get": { - "description": "Get siteinfo", + "description": "get site info", "produces": [ "application/json" ], "tags": [ "site" ], - "summary": "Get siteinfo", + "summary": "get site info", "responses": { "200": { "description": "OK", @@ -5281,11 +5292,17 @@ const docTemplate = `{ "schema.SiteGeneralReq": { "type": "object", "required": [ + "contact_email", "description", "name", - "short_description" + "short_description", + "site_url" ], "properties": { + "contact_email": { + "type": "string", + "maxLength": 512 + }, "description": { "type": "string", "maxLength": 2000 @@ -5297,17 +5314,27 @@ const docTemplate = `{ "short_description": { "type": "string", "maxLength": 255 + }, + "site_url": { + "type": "string", + "maxLength": 512 } } }, "schema.SiteGeneralResp": { "type": "object", "required": [ + "contact_email", "description", "name", - "short_description" + "short_description", + "site_url" ], "properties": { + "contact_email": { + "type": "string", + "maxLength": 512 + }, "description": { "type": "string", "maxLength": 2000 @@ -5319,6 +5346,10 @@ const docTemplate = `{ "short_description": { "type": "string", "maxLength": 255 + }, + "site_url": { + "type": "string", + "maxLength": 512 } } }, @@ -5326,7 +5357,8 @@ const docTemplate = `{ "type": "object", "required": [ "language", - "theme" + "theme", + "time_zone" ], "properties": { "language": { @@ -5340,6 +5372,10 @@ const docTemplate = `{ "theme": { "type": "string", "maxLength": 128 + }, + "time_zone": { + "type": "string", + "maxLength": 128 } } }, @@ -5347,7 +5383,8 @@ const docTemplate = `{ "type": "object", "required": [ "language", - "theme" + "theme", + "time_zone" ], "properties": { "language": { @@ -5361,6 +5398,10 @@ const docTemplate = `{ "theme": { "type": "string", "maxLength": 128 + }, + "time_zone": { + "type": "string", + "maxLength": 128 } } }, diff --git a/docs/img/install-database.png b/docs/img/install-database.png new file mode 100644 index 00000000..09fbf36a Binary files /dev/null and b/docs/img/install-database.png differ diff --git a/docs/img/install-site-info.png b/docs/img/install-site-info.png new file mode 100644 index 00000000..b8166caf Binary files /dev/null and b/docs/img/install-site-info.png differ diff --git a/docs/swagger.json b/docs/swagger.json index 039f67f5..a45709d0 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -50,12 +50,6 @@ "description": "answer id or question title", "name": "query", "in": "query" - }, - { - "type": "string", - "description": "question id", - "name": "question_id", - "in": "query" } ], "responses": { @@ -107,6 +101,34 @@ } } }, + "/answer/admin/api/dashboard": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "DashboardInfo", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "DashboardInfo", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/admin/api/language/options": { "get": { "security": [ @@ -475,14 +497,14 @@ "ApiKeyAuth": [] } ], - "description": "Get siteinfo general", + "description": "get site general information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Get siteinfo general", + "summary": "get site general information", "responses": { "200": { "description": "OK", @@ -510,14 +532,14 @@ "ApiKeyAuth": [] } ], - "description": "Get siteinfo interface", + "description": "update site general information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Get siteinfo interface", + "summary": "update site general information", "parameters": [ { "description": "general", @@ -546,25 +568,14 @@ "ApiKeyAuth": [] } ], - "description": "Get siteinfo interface", + "description": "get site interface", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Get siteinfo interface", - "parameters": [ - { - "description": "general", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/schema.AddCommentReq" - } - } - ], + "summary": "get site interface", "responses": { "200": { "description": "OK", @@ -592,14 +603,14 @@ "ApiKeyAuth": [] } ], - "description": "Get siteinfo interface", + "description": "update site info interface", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Get siteinfo interface", + "summary": "update site info interface", "parameters": [ { "description": "general", @@ -2698,14 +2709,14 @@ }, "/answer/api/v1/siteinfo": { "get": { - "description": "Get siteinfo", + "description": "get site info", "produces": [ "application/json" ], "tags": [ "site" ], - "summary": "Get siteinfo", + "summary": "get site info", "responses": { "200": { "description": "OK", @@ -5269,11 +5280,17 @@ "schema.SiteGeneralReq": { "type": "object", "required": [ + "contact_email", "description", "name", - "short_description" + "short_description", + "site_url" ], "properties": { + "contact_email": { + "type": "string", + "maxLength": 512 + }, "description": { "type": "string", "maxLength": 2000 @@ -5285,17 +5302,27 @@ "short_description": { "type": "string", "maxLength": 255 + }, + "site_url": { + "type": "string", + "maxLength": 512 } } }, "schema.SiteGeneralResp": { "type": "object", "required": [ + "contact_email", "description", "name", - "short_description" + "short_description", + "site_url" ], "properties": { + "contact_email": { + "type": "string", + "maxLength": 512 + }, "description": { "type": "string", "maxLength": 2000 @@ -5307,6 +5334,10 @@ "short_description": { "type": "string", "maxLength": 255 + }, + "site_url": { + "type": "string", + "maxLength": 512 } } }, @@ -5314,7 +5345,8 @@ "type": "object", "required": [ "language", - "theme" + "theme", + "time_zone" ], "properties": { "language": { @@ -5328,6 +5360,10 @@ "theme": { "type": "string", "maxLength": 128 + }, + "time_zone": { + "type": "string", + "maxLength": 128 } } }, @@ -5335,7 +5371,8 @@ "type": "object", "required": [ "language", - "theme" + "theme", + "time_zone" ], "properties": { "language": { @@ -5349,6 +5386,10 @@ "theme": { "type": "string", "maxLength": 128 + }, + "time_zone": { + "type": "string", + "maxLength": 128 } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c419b60e..17b2360a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -983,6 +983,9 @@ definitions: type: object schema.SiteGeneralReq: properties: + contact_email: + maxLength: 512 + type: string description: maxLength: 2000 type: string @@ -992,13 +995,21 @@ definitions: short_description: maxLength: 255 type: string + site_url: + maxLength: 512 + type: string required: + - contact_email - description - name - short_description + - site_url type: object schema.SiteGeneralResp: properties: + contact_email: + maxLength: 512 + type: string description: maxLength: 2000 type: string @@ -1008,10 +1019,15 @@ definitions: short_description: maxLength: 255 type: string + site_url: + maxLength: 512 + type: string required: + - contact_email - description - name - short_description + - site_url type: object schema.SiteInterfaceReq: properties: @@ -1024,9 +1040,13 @@ definitions: theme: maxLength: 128 type: string + time_zone: + maxLength: 128 + type: string required: - language - theme + - time_zone type: object schema.SiteInterfaceResp: properties: @@ -1039,9 +1059,13 @@ definitions: theme: maxLength: 128 type: string + time_zone: + maxLength: 128 + type: string required: - language - theme + - time_zone type: object schema.TagItem: properties: @@ -1394,10 +1418,6 @@ paths: in: query name: query type: string - - description: question id - in: query - name: question_id - type: string produces: - application/json responses: @@ -1434,6 +1454,23 @@ paths: summary: AdminSetAnswerStatus tags: - admin + /answer/admin/api/dashboard: + get: + consumes: + - application/json + description: DashboardInfo + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: DashboardInfo + tags: + - admin /answer/admin/api/language/options: get: description: Get language options @@ -1662,7 +1699,7 @@ paths: - admin /answer/admin/api/siteinfo/general: get: - description: Get siteinfo general + description: get site general information produces: - application/json responses: @@ -1677,11 +1714,11 @@ paths: type: object security: - ApiKeyAuth: [] - summary: Get siteinfo general + summary: get site general information tags: - admin put: - description: Get siteinfo interface + description: update site general information parameters: - description: general in: body @@ -1698,19 +1735,12 @@ paths: $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: Get siteinfo interface + summary: update site general information tags: - admin /answer/admin/api/siteinfo/interface: get: - description: Get siteinfo interface - parameters: - - description: general - in: body - name: data - required: true - schema: - $ref: '#/definitions/schema.AddCommentReq' + description: get site interface produces: - application/json responses: @@ -1725,11 +1755,11 @@ paths: type: object security: - ApiKeyAuth: [] - summary: Get siteinfo interface + summary: get site interface tags: - admin put: - description: Get siteinfo interface + description: update site info interface parameters: - description: general in: body @@ -1746,7 +1776,7 @@ paths: $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: Get siteinfo interface + summary: update site info interface tags: - admin /answer/admin/api/theme/options: @@ -3014,7 +3044,7 @@ paths: - Search /answer/api/v1/siteinfo: get: - description: Get siteinfo + description: get site info produces: - application/json responses: @@ -3027,7 +3057,7 @@ paths: data: $ref: '#/definitions/schema.SiteGeneralResp' type: object - summary: Get siteinfo + summary: get site info tags: - site /answer/api/v1/tag: diff --git a/go.mod b/go.mod index d6d85117..95796a93 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/goccy/go-json v0.9.11 github.com/google/uuid v1.3.0 github.com/google/wire v0.5.0 + github.com/grokify/html-strip-tags-go v0.0.1 github.com/jinzhu/copier v0.3.5 github.com/jinzhu/now v1.1.5 github.com/lib/pq v1.10.7 @@ -24,7 +25,7 @@ require ( github.com/segmentfault/pacman v1.0.1 github.com/segmentfault/pacman/contrib/cache/memory v0.0.0-20221018072427-a15dd1434e05 github.com/segmentfault/pacman/contrib/conf/viper v0.0.0-20221018072427-a15dd1434e05 - github.com/segmentfault/pacman/contrib/i18n v0.0.0-20221018072427-a15dd1434e05 + github.com/segmentfault/pacman/contrib/i18n v0.0.0-20221109042453-26158da67632 github.com/segmentfault/pacman/contrib/log/zap v0.0.0-20221018072427-a15dd1434e05 github.com/segmentfault/pacman/contrib/server/http v0.0.0-20221018072427-a15dd1434e05 github.com/spf13/cobra v1.6.1 @@ -35,6 +36,7 @@ require ( golang.org/x/crypto v0.1.0 golang.org/x/net v0.1.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df + gopkg.in/yaml.v3 v3.0.1 xorm.io/builder v0.3.12 xorm.io/core v0.7.3 xorm.io/xorm v1.3.2 @@ -110,6 +112,5 @@ require ( gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index be85073b..f12aeb7a 100644 --- a/go.sum +++ b/go.sum @@ -299,6 +299,8 @@ github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51 github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grokify/html-strip-tags-go v0.0.1 h1:0fThFwLbW7P/kOiTBs03FsJSV9RM2M/Q/MOnCQxKMo0= +github.com/grokify/html-strip-tags-go v0.0.1/go.mod h1:2Su6romC5/1VXOQMaWL2yb618ARB8iVo6/DR99A6d78= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= @@ -594,8 +596,8 @@ github.com/segmentfault/pacman/contrib/cache/memory v0.0.0-20221018072427-a15dd1 github.com/segmentfault/pacman/contrib/cache/memory v0.0.0-20221018072427-a15dd1434e05/go.mod h1:rmf1TCwz67dyM+AmTwSd1BxTo2AOYHj262lP93bOZbs= github.com/segmentfault/pacman/contrib/conf/viper v0.0.0-20221018072427-a15dd1434e05 h1:BlqTgc3/MYKG6vMI2MI+6o+7P4Gy5PXlawu185wPXAk= github.com/segmentfault/pacman/contrib/conf/viper v0.0.0-20221018072427-a15dd1434e05/go.mod h1:prPjFam7MyZ5b3S9dcDOt2tMPz6kf7C9c243s9zSwPY= -github.com/segmentfault/pacman/contrib/i18n v0.0.0-20221018072427-a15dd1434e05 h1:gFCY9KUxhYg+/MXNcDYl4ILK+R1SG78FtaSR3JqZNYY= -github.com/segmentfault/pacman/contrib/i18n v0.0.0-20221018072427-a15dd1434e05/go.mod h1:5Afm+OQdau/HQqSOp/ALlSUp0vZsMMMbv//kJhxuoi8= +github.com/segmentfault/pacman/contrib/i18n v0.0.0-20221109042453-26158da67632 h1:so07u8RWXZQ0gz30KXJ9MKtQ5zjgcDlQ/UwFZrwm5b0= +github.com/segmentfault/pacman/contrib/i18n v0.0.0-20221109042453-26158da67632/go.mod h1:5Afm+OQdau/HQqSOp/ALlSUp0vZsMMMbv//kJhxuoi8= github.com/segmentfault/pacman/contrib/log/zap v0.0.0-20221018072427-a15dd1434e05 h1:jcGZU2juv0L3eFEkuZYV14ESLUlWfGMWnP0mjOfrSZc= github.com/segmentfault/pacman/contrib/log/zap v0.0.0-20221018072427-a15dd1434e05/go.mod h1:L4GqtXLoR73obTYqUQIzfkm8NG8pvZafxFb6KZFSSHk= github.com/segmentfault/pacman/contrib/server/http v0.0.0-20221018072427-a15dd1434e05 h1:91is1nKNbfTOl8CvMYiFgg4c5Vmol+5mVmMV/jDXD+A= diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 824c92e6..f3fe2602 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -1,172 +1,1128 @@ -base: - success: - other: "Success." - unknown: - other: "Unknown error." - request_format_error: - other: "Request format is not valid." - unauthorized_error: - other: "Unauthorized." - database_error: - other: "Data server error." +# The following fields are used for back-end +backend: + base: + success: + other: "Success." + unknown: + other: "Unknown error." + request_format_error: + other: "Request format is not valid." + unauthorized_error: + other: "Unauthorized." + database_error: + other: "Data server error." -email: - other: "Email" -password: - other: "Password" - -email_or_password_wrong_error: &email_or_password_wrong - other: "Email and password do not match." - -error: - admin: - email_or_password_wrong: *email_or_password_wrong - answer: - not_found: - other: "Answer do not found." - comment: - edit_without_permission: - other: "Comment are not allowed to edit." - not_found: - other: "Comment not found." email: - duplicate: - other: "Email already exists." - need_to_be_verified: - other: "Email should be verified." - verify_url_expired: - other: "Email verified URL has expired, please resend the email." - lang: - not_found: - other: "Language file not found." - object: - captcha_verification_failed: - other: "Captcha wrong." - disallow_follow: - other: "You are not allowed to follow." - disallow_vote: - other: "You are not allowed to vote." - disallow_vote_your_self: - other: "You can't vote for your own post." - not_found: - other: "Object not found." - verification_failed: - other: "Verification failed." - email_or_password_incorrect: - other: "Email and password do not match." - old_password_verification_failed: - other: "The old password verification failed" - new_password_same_as_previous_setting: - other: "The new password is the same as the previous one." - question: - not_found: - other: "Question not found." - rank: - fail_to_meet_the_condition: - other: "Rank fail to meet the condition." + other: "Email" + password: + other: "Password" + + email_or_password_wrong_error: &email_or_password_wrong + other: "Email and password do not match." + + error: + admin: + email_or_password_wrong: *email_or_password_wrong + answer: + not_found: + other: "Answer do not found." + comment: + edit_without_permission: + other: "Comment are not allowed to edit." + not_found: + other: "Comment not found." + email: + duplicate: + other: "Email already exists." + need_to_be_verified: + other: "Email should be verified." + verify_url_expired: + other: "Email verified URL has expired, please resend the email." + lang: + not_found: + other: "Language file not found." + object: + captcha_verification_failed: + other: "Captcha wrong." + disallow_follow: + other: "You are not allowed to follow." + disallow_vote: + other: "You are not allowed to vote." + disallow_vote_your_self: + other: "You can't vote for your own post." + not_found: + other: "Object not found." + verification_failed: + other: "Verification failed." + email_or_password_incorrect: + other: "Email and password do not match." + old_password_verification_failed: + other: "The old password verification failed" + new_password_same_as_previous_setting: + other: "The new password is the same as the previous one." + question: + not_found: + other: "Question not found." + rank: + fail_to_meet_the_condition: + other: "Rank fail to meet the condition." + report: + handle_failed: + other: "Report handle failed." + not_found: + other: "Report not found." + tag: + not_found: + other: "Tag not found." + theme: + not_found: + other: "Theme not found." + user: + email_or_password_wrong: + other: *email_or_password_wrong + not_found: + other: "User not found." + suspended: + other: "User has been suspended." + username_invalid: + other: "Username is invalid." + username_duplicate: + other: "Username is already in use." + set_avatar: + other: "Avatar set failed." + + config: + read_config_failed: + other: "Read config failed" + database: + connection_failed: + other: "Database connection failed" + create_table_failed: + other: "Create table failed" + install: + create_config_failed: + other: "Can’t create the config.yaml file." report: - handle_failed: - other: "Report handle failed." - not_found: - other: "Report not found." - tag: - not_found: - other: "Tag not found." - theme: - not_found: - other: "Theme not found." - user: - email_or_password_wrong: - other: *email_or_password_wrong - not_found: - other: "User not found." - suspended: - other: "User has been suspended." - username_invalid: - other: "Username is invalid." - username_duplicate: - other: "Username is already in use." - set_avatar: - other: "Avatar set failed." - -report: - spam: - name: - other: "spam" - description: - other: "This post is an advertisement, or vandalism. It is not useful or relevant to the current topic." - rude: - name: - other: "rude or abusive" - description: - other: "A reasonable person would find this content inappropriate for respectful discourse." - duplicate: - name: - other: "a duplicate" - description: - other: "This question has been asked before and already has an answer." - not_answer: - name: - other: "not an answer" - description: - other: "This was posted as an answer, but it does not attempt to answer the question. It should possibly be an edit, a comment, another question, or deleted altogether." - not_need: - name: - other: "no longer needed" - description: - other: "This comment is outdated, conversational or not relevant to this post." - other: - name: - other: "something else" - description: - other: "This post requires staff attention for another reason not listed above." - -question: - close: - duplicate: + spam: name: other: "spam" + description: + other: "This post is an advertisement, or vandalism. It is not useful or relevant to the current topic." + rude: + name: + other: "rude or abusive" + description: + other: "A reasonable person would find this content inappropriate for respectful discourse." + duplicate: + name: + other: "a duplicate" description: other: "This question has been asked before and already has an answer." - guideline: + not_answer: name: - other: "a community-specific reason" + other: "not an answer" description: - other: "This question doesn't meet a community guideline." - multiple: + other: "This was posted as an answer, but it does not attempt to answer the question. It should possibly be an edit, a comment, another question, or deleted altogether." + not_need: name: - other: "needs details or clarity" + other: "no longer needed" description: - other: "This question currently includes multiple questions in one. It should focus on one problem only." + other: "This comment is outdated, conversational or not relevant to this post." other: name: other: "something else" description: - other: "This post requires another reason not listed above." + other: "This post requires staff attention for another reason not listed above." -notification: - action: - update_question: - other: "updated question" - answer_the_question: - other: "answered question" - update_answer: - other: "updated answer" - adopt_answer: - other: "accepted answer" - comment_question: - other: "commented question" - comment_answer: - other: "commented answer" - reply_to_you: - other: "replied to you" - mention_you: - other: "mentioned you" - your_question_is_closed: - other: "Your question has been closed" - your_question_was_deleted: - other: "Your question has been deleted" - your_answer_was_deleted: - other: "Your answer has been deleted" - your_comment_was_deleted: - other: "Your comment has been deleted" + question: + close: + duplicate: + name: + other: "spam" + description: + other: "This question has been asked before and already has an answer." + guideline: + name: + other: "a community-specific reason" + description: + other: "This question doesn't meet a community guideline." + multiple: + name: + other: "needs details or clarity" + description: + other: "This question currently includes multiple questions in one. It should focus on one problem only." + other: + name: + other: "something else" + description: + other: "This post requires another reason not listed above." + + notification: + action: + update_question: + other: "updated question" + answer_the_question: + other: "answered question" + update_answer: + other: "updated answer" + adopt_answer: + other: "accepted answer" + comment_question: + other: "commented question" + comment_answer: + other: "commented answer" + reply_to_you: + other: "replied to you" + mention_you: + other: "mentioned you" + your_question_is_closed: + other: "Your question has been closed" + your_question_was_deleted: + other: "Your question has been deleted" + your_answer_was_deleted: + other: "Your answer has been deleted" + your_comment_was_deleted: + other: "Your comment has been deleted" +# The following fields are used for interface presentation(Front-end) +ui: + how_to_format: + title: How to Format + description: >- + + pagination: + prev: Prev + next: Next + page_title: + question: Question + questions: Questions + tag: Tag + tags: Tags + tag_wiki: tag wiki + edit_tag: Edit Tag + ask_a_question: Add Question + edit_question: Edit Question + edit_answer: Edit Answer + search: Search + posts_containing: Posts containing + settings: Settings + notifications: Notifications + login: Log In + sign_up: Sign Up + account_recovery: Account Recovery + account_activation: Account Activation + confirm_email: Confirm Email + account_suspended: Account Suspended + admin: Admin + change_email: Modify Email + install: Answer Installation + upgrade: Answer Upgrade + maintenance: Website Maintenance + notifications: + title: Notifications + inbox: Inbox + achievement: Achievements + all_read: Mark all as read + show_more: Show more + suspended: + title: Your Account has been Suspended + until_time: 'Your account was suspended until {{ time }}.' + forever: This user was suspended forever. + end: You don't meet a community guideline. + editor: + blockquote: + text: Blockquote + bold: + text: Strong + chart: + text: Chart + flow_chart: Flow chart + sequence_diagram: Sequence diagram + class_diagram: Class diagram + state_diagram: State diagram + entity_relationship_diagram: Entity relationship diagram + user_defined_diagram: User defined diagram + gantt_chart: Gantt chart + pie_chart: Pie chart + code: + text: Code Sample + add_code: Add code sample + form: + fields: + code: + label: Code + msg: + empty: Code cannot be empty. + language: + label: Language (optional) + placeholder: Automatic detection + btn_cancel: Cancel + btn_confirm: Add + formula: + text: Formula + options: + inline: Inline formula + block: Block formula + heading: + text: Heading + options: + h1: Heading 1 + h2: Heading 2 + h3: Heading 3 + h4: Heading 4 + h5: Heading 5 + h6: Heading 6 + help: + text: Help + hr: + text: Horizontal Rule + image: + text: Image + add_image: Add image + tab_image: Upload image + form_image: + fields: + file: + label: Image File + btn: Select image + msg: + empty: File cannot be empty. + only_image: Only image files are allowed. + max_size: File size cannot exceed 4MB. + description: + label: Description (optional) + tab_url: Image URL + form_url: + fields: + url: + label: Image URL + msg: + empty: Image URL cannot be empty. + name: + label: Description (optional) + btn_cancel: Cancel + btn_confirm: Add + uploading: Uploading + indent: + text: Indent + outdent: + text: Outdent + italic: + text: Emphasis + link: + text: Hyperlink + add_link: Add hyperlink + form: + fields: + url: + label: URL + msg: + empty: URL cannot be empty. + name: + label: Description (optional) + btn_cancel: Cancel + btn_confirm: Add + ordered_list: + text: Numbered List + unordered_list: + text: Bulleted List + table: + text: Table + heading: Heading + cell: Cell + close_modal: + title: I am closing this post as... + btn_cancel: Cancel + btn_submit: Submit + remark: + empty: Cannot be empty. + msg: + empty: Please select a reason. + report_modal: + flag_title: I am flagging to report this post as... + close_title: I am closing this post as... + review_question_title: Review question + review_answer_title: Review answer + review_comment_title: Review comment + btn_cancel: Cancel + btn_submit: Submit + remark: + empty: Cannot be empty. + msg: + empty: Please select a reason. + tag_modal: + title: Create new tag + form: + fields: + display_name: + label: Display Name + msg: + empty: Display name cannot be empty. + range: Display name up to 35 characters. + slug_name: + label: URL Slug + description: 'Must use the character set "a-z", "0-9", "+ # - ."' + msg: + empty: URL slug cannot be empty. + range: URL slug up to 35 characters. + character: URL slug contains unallowed character set. + description: + label: Description (optional) + btn_cancel: Cancel + btn_submit: Submit + tag_info: + created_at: Created + edited_at: Edited + synonyms: + title: Synonyms + text: The following tags will be remapped to + empty: No synonyms found. + btn_add: Add a synonym + btn_edit: Edit + btn_save: Save + synonyms_text: The following tags will be remapped to + delete: + title: Delete this tag + content: >- +

We do not allowed deleting tag with posts.

Please remove this tag + from the posts first.

+ content2: Are you sure you wish to delete? + close: Close + edit_tag: + title: Edit Tag + default_reason: Edit tag + form: + fields: + revision: + label: Revision + display_name: + label: Display Name + slug_name: + label: URL Slug + info: 'Must use the character set "a-z", "0-9", "+ # - ."' + description: + label: Description + edit_summary: + label: Edit Summary + placeholder: >- + Briefly explain your changes (corrected spelling, fixed grammar, + improved formatting) + btn_save_edits: Save edits + btn_cancel: Cancel + dates: + long_date: MMM D + long_date_with_year: 'MMM D, YYYY' + long_date_with_time: 'MMM D, YYYY [at] HH:mm' + now: now + x_seconds_ago: '{{count}}s ago' + x_minutes_ago: '{{count}}m ago' + x_hours_ago: '{{count}}h ago' + hour: hour + day: day + comment: + btn_add_comment: Add comment + reply_to: Reply to + btn_reply: Reply + btn_edit: Edit + btn_delete: Delete + btn_flag: Flag + btn_save_edits: Save edits + btn_cancel: Cancel + show_more: Show more comment + tip_question: >- + Use comments to ask for more information or suggest improvements. Avoid + answering questions in comments. + tip_answer: >- + Use comments to reply to other users or notify them of changes. If you are + adding new information, edit your post instead of commenting. + edit_answer: + title: Edit Answer + default_reason: Edit answer + form: + fields: + revision: + label: Revision + answer: + label: Answer + edit_summary: + label: Edit Summary + placeholder: >- + Briefly explain your changes (corrected spelling, fixed grammar, + improved formatting) + btn_save_edits: Save edits + btn_cancel: Cancel + tags: + title: Tags + sort_buttons: + popular: Popular + name: Name + newest: newest + button_follow: Follow + button_following: Following + tag_label: questions + search_placeholder: Filter by tag name + no_description: The tag has no description. + more: More + ask: + title: Add Question + edit_title: Edit Question + default_reason: Edit question + similar_questions: Similar questions + form: + fields: + revision: + label: Revision + title: + label: Title + placeholder: Be specific and imagine you're asking a question to another person + msg: + empty: Title cannot be empty. + range: Title up to 150 characters + body: + label: Body + msg: + empty: Body cannot be empty. + tags: + label: Tags + msg: + empty: Tags cannot be empty. + answer: + label: Answer + msg: + empty: Answer cannot be empty. + btn_post_question: Post your question + btn_save_edits: Save edits + answer_question: Answer your own question + post_question&answer: Post your question and answer + tag_selector: + add_btn: Add tag + create_btn: Create new tag + search_tag: Search tag + hint: 'Describe what your question is about, at least one tag is required.' + no_result: No tags matched + header: + nav: + question: Questions + tag: Tags + user: Users + profile: Profile + setting: Settings + logout: Log out + admin: Admin + search: + placeholder: Search + footer: + build_on: >- + Built on <1> Answer - the open-source software that power Q&A + communities
Made with love © 2022 Answer + upload_img: + name: Change + loading: loading... + pic_auth_code: + title: Captcha + placeholder: Type the text above + msg: + empty: Captcha cannot be empty. + inactive: + first: >- + You're almost done! We sent an activation mail to {{mail}}. + Please follow the instructions in the mail to activate your account. + info: 'If it doesn''t arrive, check your spam folder.' + another: >- + We sent another activation email to you at {{mail}}. It might + take a few minutes for it to arrive; be sure to check your spam folder. + btn_name: Resend activation email + change_btn_name: Change email + msg: + empty: Cannot be empty. + login: + page_title: Welcome to Answer + info_sign: Don't have an account? <1>Sign up + info_login: Already have an account? <1>Log in + forgot_pass: Forgot password? + name: + label: Name + msg: + empty: Name cannot be empty. + range: Name up to 30 characters. + email: + label: Email + msg: + empty: Email cannot be empty. + password: + label: Password + msg: + empty: Password cannot be empty. + different: The passwords entered on both sides are inconsistent + account_forgot: + page_title: Forgot Your Password + btn_name: Send me recovery email + send_success: >- + If an account matches {{mail}}, you should receive an email + with instructions on how to reset your password shortly. + email: + label: Email + msg: + empty: Email cannot be empty. + change_email: + page_title: Welcome to Answer + btn_cancel: Cancel + btn_update: Update email address + send_success: >- + If an account matches {{mail}}, you should receive an email + with instructions on how to reset your password shortly. + email: + label: New Email + msg: + empty: Email cannot be empty. + password_reset: + page_title: Password Reset + btn_name: Reset my password + reset_success: >- + You successfully changed your password; you will be redirected to the log in + page. + link_invalid: >- + Sorry, this password reset link is no longer valid. Perhaps your password is + already reset? + to_login: Continue to log in page + password: + label: Password + msg: + empty: Password cannot be empty. + length: The length needs to be between 8 and 32 + different: The passwords entered on both sides are inconsistent + password_confirm: + label: Confirm New Password + settings: + page_title: Settings + nav: + profile: Profile + notification: Notifications + account: Account + interface: Interface + profile: + btn_name: Update profile + display_name: + label: Display Name + msg: Display name cannot be empty. + msg_range: Display name up to 30 characters + username: + label: Username + caption: People can mention you as "@username". + msg: Username cannot be empty. + msg_range: Username up to 30 characters + character: 'Must use the character set "a-z", "0-9", " - . _"' + avatar: + label: Profile Image + gravatar: Gravatar + gravatar_text: You can change image on <1>gravatar.com + custom: Custom + btn_refresh: Refresh + custom_text: You can upload your image. + default: Default + msg: Please upload an avatar + bio: + label: About Me (optional) + website: + label: Website (optional) + placeholder: 'https://example.com' + msg: Website incorrect format + location: + label: Location (optional) + placeholder: 'City, Country' + notification: + email: + label: Email Notifications + radio: 'Answers to your questions, comments, and more' + account: + change_email_btn: Change email + change_pass_btn: Change password + change_email_info: >- + We've sent an email to that address. Please follow the confirmation + instructions. + email: + label: Email + msg: Email cannot be empty. + password_title: Password + current_pass: + label: Current Password + msg: + empty: Current Password cannot be empty. + length: The length needs to be between 8 and 32. + different: The two entered passwords do not match. + new_pass: + label: New Password + pass_confirm: + label: Confirm New Password + interface: + lang: + label: Interface Language + text: User interface language. It will change when you refresh the page. + toast: + update: update success + update_password: Password changed successfully. + flag_success: Thanks for flagging. + related_question: + title: Related Questions + btn: Add question + answers: answers + question_detail: + Asked: Asked + asked: asked + update: Modified + edit: edited + Views: Viewed + Follow: Follow + Following: Following + answered: answered + closed_in: Closed in + show_exist: Show existing question. + answers: + title: Answers + score: Score + newest: Newest + btn_accept: Accept + btn_accepted: Accepted + write_answer: + title: Your Answer + btn_name: Post your answer + confirm_title: Continue to answer + continue: Continue + confirm_info: >- +

Are you sure you want to add another answer?

You could use the + edit link to refine and improve your existing answer, instead.

+ empty: Answer cannot be empty. + delete: + title: Delete this post + question: >- + We do not recommend deleting questions with answers because + doing so deprives future readers of this knowledge.

Repeated deletion + of answered questions can result in your account being blocked from asking. + Are you sure you wish to delete? + answer_accepted: >- +

We do not recommend deleting accepted answer because + doing so deprives future readers of this knowledge.

Repeated deletion + of accepted answers can result in your account being blocked from answering. + Are you sure you wish to delete? + other: Are you sure you wish to delete? + tip_question_deleted: This post has been deleted + tip_answer_deleted: This answer has been deleted + btns: + confirm: Confirm + cancel: Cancel + save: Save + delete: Delete + login: Log in + signup: Sign up + logout: Log out + verify: Verify + add_question: Add question + search: + title: Search Results + keywords: Keywords + options: Options + follow: Follow + following: Following + counts: '{{count}} Results' + more: More + sort_btns: + relevance: Relevance + newest: Newest + active: Active + score: Score + more: More + tips: + title: Advanced Search Tips + tag: '<1>[tag] search withing a tag' + user: '<1>user:username search by author' + answer: '<1>answers:0 unanswered questions' + score: '<1>score:3 posts with a 3+ score' + question: '<1>is:question search questions' + is_answer: '<1>is:answer search answers' + empty: We couldn't find anything.
Try different or less specific keywords. + share: + name: Share + copy: Copy link + via: Share post via... + copied: Copied + facebook: Share to Facebook + twitter: Share to Twitter + cannot_vote_for_self: You can't vote for your own post + modal_confirm: + title: Error... + account_result: + page_title: Welcome to Answer + success: Your new account is confirmed; you will be redirected to the home page. + link: Continue to homepage + invalid: >- + Sorry, this account confirmation link is no longer valid. Perhaps your + account is already active? + confirm_new_email: Your email has been updated. + confirm_new_email_invalid: >- + Sorry, this confirmation link is no longer valid. Perhaps your email was + already changed? + question: + following_tags: Following Tags + edit: Edit + save: Save + follow_tag_tip: Follow tags to curate your list of questions. + hot_questions: Hot Questions + all_questions: All Questions + x_questions: '{{ count }} Questions' + x_answers: '{{ count }} answers' + questions: Questions + answers: Answers + newest: Newest + active: Active + frequent: Frequent + score: Score + unanswered: Unanswered + modified: modified + answered: answered + asked: asked + closed: closed + follow_a_tag: Follow a tag + more: More + personal: + overview: Overview + answers: Answers + answer: answer + questions: Questions + question: question + bookmarks: Bookmarks + reputation: Reputation + comments: Comments + votes: Votes + newest: Newest + score: Score + edit_profile: Edit Profile + visited_x_days: 'Visited {{ count }} days' + viewed: Viewed + joined: Joined + last_login: Seen + about_me: About Me + about_me_empty: '// Hello, World !' + top_answers: Top Answers + top_questions: Top Questions + stats: Stats + list_empty: No posts found.
Perhaps you'd like to select a different tab? + accepted: Accepted + answered: answered + asked: asked + upvote: upvote + downvote: downvote + mod_short: Mod + mod_long: Moderators + x_reputation: reputation + x_votes: votes received + x_answers: answers + x_questions: questions + install: + title: Answer + next: Next + done: Done + config_yaml_error: Can’t create the config.yaml file. + 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 file manually in the + <1>/var/wwww/xxx/ directory and paste the following text into it. + info: 'After you’ve done that, click “Next” button.' + site_information: Site Information + admin_account: Admin Account + site_name: + label: Site Name + msg: Site Name cannot be empty. + site_url: + label: Site URL + text: The address of your site. + msg: + empty: Site URL cannot be empty. + incorrect: Site URL incorrect format. + contact_email: + label: Contact Email + text: Email address of key contact responsible for this site. + msg: + empty: Contact Email cannot be empty. + incorrect: Contact Email incorrect format. + admin_name: + label: Name + msg: Name cannot be empty. + admin_password: + label: Password + text: >- + You will need this password to log in. Please store it in a secure + location. + msg: Password cannot be empty. + admin_email: + label: Email + text: You will need this email to log in. + msg: + empty: Email cannot be empty. + incorrect: Email incorrect format. + ready_title: Your Answer is Ready! + ready_description: >- + If you ever feel like changing more settings, visit <1>admin section; + find it in the site menu. + good_luck: 'Have fun, and good luck!' + warn_title: Warning + warn_description: >- + The file <1>config.yaml already exists. If you need to reset any of the + configuration items in this file, please delete it first. + install_now: You may try <1>installing now. + installed: Already installed + installed_description: >- + You appear to have already installed. To reinstall please clear your old + database tables first. + db_failed: Database connection failed + db_failed_description: >- + This either means that the database information in your <1>config.yaml file is incorrect or that contact with the database server could not be established. This could mean your host’s database server is down. + + page_404: + description: 'Unfortunately, this page doesn''t exist.' + back_home: Back to homepage + page_50X: + description: The server encountered an error and could not complete your request. + back_home: Back to homepage + page_maintenance: + description: 'We are under maintenance, we’ll be back soon.' + admin: + admin_header: + title: Admin + nav_menus: + dashboard: Dashboard + contents: Contents + questions: Questions + answers: Answers + users: Users + flags: Flags + settings: Settings + general: General + interface: Interface + smtp: SMTP + dashboard: + title: Dashboard + welcome: Welcome to Answer Admin! + 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 + pending: Pending + completed: Completed + flagged: Flagged + created: Created + action: Action + review: Review + change_modal: + title: Change user status to... + btn_cancel: Cancel + btn_submit: Submit + normal_name: normal + normal_description: A normal user can ask and answer questions. + suspended_name: suspended + suspended_description: A suspended user can't log in. + deleted_name: deleted + deleted_description: 'Delete profile, authentication associations.' + inactive_name: inactive + inactive_description: An inactive user must re-validate their email. + confirm_title: Delete this user + confirm_content: Are you sure you want to delete this user? This is permanent! + confirm_btn: Delete + msg: + empty: Please select a reason. + status_modal: + title: 'Change {{ type }} status to...' + normal_name: normal + normal_description: A normal post available to everyone. + closed_name: closed + closed_description: 'A closed question can''t answer, but still can edit, vote and comment.' + deleted_name: deleted + deleted_description: All reputation gained and lost will be restored. + btn_cancel: Cancel + btn_submit: Submit + btn_next: Next + users: + title: Users + name: Name + email: Email + reputation: Reputation + created_at: Created Time + delete_at: Deleted Time + suspend_at: Suspended Time + status: Status + action: Action + change: Change + all: All + inactive: Inactive + suspended: Suspended + deleted: Deleted + normal: Normal + filter: + placeholder: 'Filter by name, user:id' + questions: + page_title: Questions + normal: Normal + closed: Closed + deleted: Deleted + post: Post + votes: Votes + answers: Answers + created: Created + status: Status + action: Action + change: Change + filter: + placeholder: 'Filter by title, question:id' + answers: + page_title: Answers + normal: Normal + deleted: Deleted + post: Post + votes: Votes + created: Created + status: Status + action: Action + change: Change + filter: + placeholder: 'Filter by title, answer:id' + general: + page_title: General + name: + label: Site Name + msg: Site name cannot be empty. + text: 'The name of this site, as used in the title tag.' + site_url: + label: Site URL + msg: Site url cannot be empty. + validate: Please enter a valid URL. + text: The address of your site. + short_description: + label: Short Site Description (optional) + msg: Short site description cannot be empty. + text: 'Short description, as used in the title tag on homepage.' + description: + label: Site Description (optional) + msg: Site description cannot be empty. + text: 'Describe this site in one sentence, as used in the meta description tag.' + contact_email: + label: Contact Email + msg: Contact email cannot be empty. + validate: Contact email is not valid. + text: Email address of key contact responsible for this site. + interface: + page_title: Interface + logo: + label: Logo (optional) + msg: Site logo cannot be empty. + text: You can upload your image or <1>reset it to the site title text. + theme: + label: Theme + msg: Theme cannot be empty. + text: Select an existing theme. + language: + label: Interface Language + msg: Interface language cannot be empty. + text: User interface language. It will change when you refresh the page. + time_zone: + label: Timezone + msg: Timezone cannot be empty. + text: Choose a city in the same timezone as you. + smtp: + page_title: SMTP + from_email: + label: From Email + msg: From email cannot be empty. + text: The email address which emails are sent from. + from_name: + label: From Name + msg: From name cannot be empty. + text: The name which emails are sent from. + smtp_host: + label: SMTP Host + msg: SMTP host cannot be empty. + text: Your mail server. + encryption: + label: Encryption + msg: Encryption cannot be empty. + text: For most servers SSL is the recommended option. + ssl: SSL + none: None + smtp_port: + label: SMTP Port + msg: SMTP port must be number 1 ~ 65535. + text: The port to your mail server. + smtp_username: + label: SMTP Username + msg: SMTP username cannot be empty. + smtp_password: + label: SMTP Password + msg: SMTP password cannot be empty. + test_email_recipient: + label: Test Email Recipients + text: Provide email address that will receive test sends. + msg: Test email recipients is invalid + smtp_authentication: + label: SMTP Authentication + msg: SMTP authentication cannot be empty. + 'yes': 'Yes' + 'no': 'No' diff --git a/i18n/i18n.yaml b/i18n/i18n.yaml new file mode 100644 index 00000000..13a6c522 --- /dev/null +++ b/i18n/i18n.yaml @@ -0,0 +1,6 @@ +# all support language +language_options: + - label: "简体中文(CN)" + value: "zh_CN" + - label: "English(US)" + value: "en_US" diff --git a/i18n/it_IT.yaml b/i18n/it_IT.yaml index 0b943941..61de40f8 100644 --- a/i18n/it_IT.yaml +++ b/i18n/it_IT.yaml @@ -1,170 +1,172 @@ -base: - success: - other: "Successo" - unknown: - other: "Errore sconosciuto" - request_format_error: - other: "Il formato della richiesta non è valido" - unauthorized_error: - other: "Non autorizzato" - database_error: - other: "Errore server dati" +# The following fields are used for back-end +backend: + base: + success: + other: "Successo" + unknown: + other: "Errore sconosciuto" + request_format_error: + other: "Il formato della richiesta non è valido" + unauthorized_error: + other: "Non autorizzato" + database_error: + other: "Errore server dati" -email: - other: "email" -password: - other: "password" - -email_or_password_wrong_error: &email_or_password_wrong - other: "Email o password errati" - -error: - admin: - email_or_password_wrong: *email_or_password_wrong - answer: - not_found: - other: "Risposta non trovata" - comment: - edit_without_permission: - other: "Non si hanno di privilegi sufficienti per modificare il commento" - not_found: - other: "Commento non trovato" email: - duplicate: - other: "email già esistente" - need_to_be_verified: - other: "email deve essere verificata" - verify_url_expired: - other: "l'url di verifica email è scaduto, si prega di reinviare la email" - lang: - not_found: - other: "lingua non trovata" - object: - captcha_verification_failed: - other: "captcha errato" - disallow_follow: - other: "Non sei autorizzato a seguire" - disallow_vote: - other: "non sei autorizzato a votare" - disallow_vote_your_self: - other: "Non puoi votare un tuo post!" - not_found: - other: "oggetto non trovato" - verification_failed: - other: "verifica fallita" - email_or_password_incorrect: - other: "email o password incorretti" - old_password_verification_failed: - other: "la verifica della vecchia password è fallita" - new_password_same_as_previous_setting: - other: "La nuova password è identica alla precedente" - question: - not_found: - other: "domanda non trovata" - rank: - fail_to_meet_the_condition: - other: "Condizioni non valide per il grado" + other: "email" + password: + other: "password" + + email_or_password_wrong_error: &email_or_password_wrong + other: "Email o password errati" + + error: + admin: + email_or_password_wrong: *email_or_password_wrong + answer: + not_found: + other: "Risposta non trovata" + comment: + edit_without_permission: + other: "Non si hanno di privilegi sufficienti per modificare il commento" + not_found: + other: "Commento non trovato" + email: + duplicate: + other: "email già esistente" + need_to_be_verified: + other: "email deve essere verificata" + verify_url_expired: + other: "l'url di verifica email è scaduto, si prega di reinviare la email" + lang: + not_found: + other: "lingua non trovata" + object: + captcha_verification_failed: + other: "captcha errato" + disallow_follow: + other: "Non sei autorizzato a seguire" + disallow_vote: + other: "non sei autorizzato a votare" + disallow_vote_your_self: + other: "Non puoi votare un tuo post!" + not_found: + other: "oggetto non trovato" + verification_failed: + other: "verifica fallita" + email_or_password_incorrect: + other: "email o password incorretti" + old_password_verification_failed: + other: "la verifica della vecchia password è fallita" + new_password_same_as_previous_setting: + other: "La nuova password è identica alla precedente" + question: + not_found: + other: "domanda non trovata" + rank: + fail_to_meet_the_condition: + other: "Condizioni non valide per il grado" + report: + handle_failed: + other: "Gestione del report fallita" + not_found: + other: "Report non trovato" + tag: + not_found: + other: "Etichetta non trovata" + theme: + not_found: + other: "tema non trovato" + user: + email_or_password_wrong: + other: *email_or_password_wrong + not_found: + other: "utente non trovato" + suspended: + other: "utente sospeso" + username_invalid: + other: "utente non valido" + username_duplicate: + other: "utente già in uso" + report: - handle_failed: - other: "Gestione del report fallita" - not_found: - other: "Report non trovato" - tag: - not_found: - other: "Etichetta non trovata" - theme: - not_found: - other: "tema non trovato" - user: - email_or_password_wrong: - other: *email_or_password_wrong - not_found: - other: "utente non trovato" - suspended: - other: "utente sospeso" - username_invalid: - other: "utente non valido" - username_duplicate: - other: "utente già in uso" - -report: - spam: - name: - other: "spam" - description: - other: "Questo articolo è una pubblicità o vandalismo. Non è utile o rilevante all'argomento corrente" - rude: - name: - other: "scortese o violento" - description: - other: "Una persona ragionevole trova questo contenuto inappropriato a un discorso rispettoso" - duplicate: - name: - other: "duplicato" - description: - other: "Questa domanda è già stata posta e ha già una risposta." - not_answer: - name: - other: "non è una risposta" - description: - other: "Questo è stato postato come una risposta, ma non sta cercando di rispondere alla domanda. Dovrebbe essere una modifica, un commento, un'altra domanda o cancellato del tutto." - not_need: - name: - other: "non più necessario" - description: - other: "Questo commento è datato, conversazionale o non rilevante a questo articolo." - other: - name: - other: "altro" - description: - other: "Questo articolo richiede una supervisione dello staff per altre ragioni non listate sopra." - -question: - close: - duplicate: + spam: name: other: "spam" description: - other: "Questa domanda è già stata chiesta o ha già una risposta." - guideline: + other: "Questo articolo è una pubblicità o vandalismo. Non è utile o rilevante all'argomento corrente" + rude: name: - other: "motivo legato alla community" + other: "scortese o violento" description: - other: "Questa domanda non soddisfa le linee guida della comunità." - multiple: + other: "Una persona ragionevole trova questo contenuto inappropriato a un discorso rispettoso" + duplicate: name: - other: "richiede maggiori dettagli o chiarezza" + other: "duplicato" description: - other: "Questa domanda attualmente contiene più domande. Deve concentrarsi solamente su un unico problema." + other: "Questa domanda è già stata posta e ha già una risposta." + not_answer: + name: + other: "non è una risposta" + description: + other: "Questo è stato postato come una risposta, ma non sta cercando di rispondere alla domanda. Dovrebbe essere una modifica, un commento, un'altra domanda o cancellato del tutto." + not_need: + name: + other: "non più necessario" + description: + other: "Questo commento è datato, conversazionale o non rilevante a questo articolo." other: name: other: "altro" description: - other: "Questo articolo richiede un'altro motivo non listato sopra." + other: "Questo articolo richiede una supervisione dello staff per altre ragioni non listate sopra." -notification: - action: - update_question: - other: "domanda aggiornata" - answer_the_question: - other: "domanda risposta" - update_answer: - other: "risposta aggiornata" - adopt_answer: - other: "risposta accettata" - comment_question: - other: "domanda commentata" - comment_answer: - other: "risposta commentata" - reply_to_you: - other: "hai ricevuto risposta" - mention_you: - other: "sei stato menzionato" - your_question_is_closed: - other: "la tua domanda è stata chiusa" - your_question_was_deleted: - other: "la tua domanda è stata rimossa" - your_answer_was_deleted: - other: "la tua risposta è stata rimossa" - your_comment_was_deleted: - other: "il tuo commento è stato rimosso" + question: + close: + duplicate: + name: + other: "spam" + description: + other: "Questa domanda è già stata chiesta o ha già una risposta." + guideline: + name: + other: "motivo legato alla community" + description: + other: "Questa domanda non soddisfa le linee guida della comunità." + multiple: + name: + other: "richiede maggiori dettagli o chiarezza" + description: + other: "Questa domanda attualmente contiene più domande. Deve concentrarsi solamente su un unico problema." + other: + name: + other: "altro" + description: + other: "Questo articolo richiede un'altro motivo non listato sopra." + + notification: + action: + update_question: + other: "domanda aggiornata" + answer_the_question: + other: "domanda risposta" + update_answer: + other: "risposta aggiornata" + adopt_answer: + other: "risposta accettata" + comment_question: + other: "domanda commentata" + comment_answer: + other: "risposta commentata" + reply_to_you: + other: "hai ricevuto risposta" + mention_you: + other: "sei stato menzionato" + your_question_is_closed: + other: "la tua domanda è stata chiusa" + your_question_was_deleted: + other: "la tua domanda è stata rimossa" + your_answer_was_deleted: + other: "la tua risposta è stata rimossa" + your_comment_was_deleted: + other: "il tuo commento è stato rimosso" diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index 7d76cc06..2e476f82 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -1,172 +1,921 @@ -base: - success: - other: "成功" - unknown: - other: "未知错误" - request_format_error: - other: "请求格式错误" - unauthorized_error: - other: "未登录" - database_error: - other: "数据服务异常" +backend: + base: + success: + other: "成功" + unknown: + other: "未知错误" + request_format_error: + other: "请求格式错误" + unauthorized_error: + other: "未登录" + database_error: + other: "数据服务异常" -email: - other: "邮箱" -password: - other: "密码" - -email_or_password_wrong_error: &email_or_password_wrong - other: "邮箱或密码错误" - -error: - admin: - email_or_password_wrong: *email_or_password_wrong - answer: - not_found: - other: "答案未找到" - comment: - edit_without_permission: - other: "不允许编辑评论" - not_found: - other: "评论未找到" email: - duplicate: - other: "邮箱已经存在" - need_to_be_verified: - other: "邮箱需要验证" - verify_url_expired: - other: "邮箱验证的网址已过期,请重新发送邮件" - lang: - not_found: - other: "语言未找到" - object: - captcha_verification_failed: - other: "验证码错误" - disallow_follow: - other: "你不能关注" - disallow_vote: - other: "你不能投票" - disallow_vote_your_self: - other: "你不能为自己的帖子投票!" - not_found: - other: "对象未找到" - verification_failed: - other: "验证失败" - email_or_password_incorrect: - other: "邮箱或密码不正确" - old_password_verification_failed: - other: "旧密码验证失败" - new_password_same_as_previous_setting: - other: "新密码与之前的设置相同" - question: - not_found: - other: "问题未找到" - rank: - fail_to_meet_the_condition: - other: "级别不符合条件" + other: "邮箱" + password: + other: "密码" + + email_or_password_wrong_error: &email_or_password_wrong + other: "邮箱或密码错误" + + error: + admin: + email_or_password_wrong: *email_or_password_wrong + answer: + not_found: + other: "答案未找到" + comment: + edit_without_permission: + other: "不允许编辑评论" + not_found: + other: "评论未找到" + email: + duplicate: + other: "邮箱已经存在" + need_to_be_verified: + other: "邮箱需要验证" + verify_url_expired: + other: "邮箱验证的网址已过期,请重新发送邮件" + lang: + not_found: + other: "语言未找到" + object: + captcha_verification_failed: + other: "验证码错误" + disallow_follow: + other: "你不能关注" + disallow_vote: + other: "你不能投票" + disallow_vote_your_self: + other: "你不能为自己的帖子投票!" + not_found: + other: "对象未找到" + verification_failed: + other: "验证失败" + email_or_password_incorrect: + other: "邮箱或密码不正确" + old_password_verification_failed: + other: "旧密码验证失败" + new_password_same_as_previous_setting: + other: "新密码与之前的设置相同" + question: + not_found: + other: "问题未找到" + rank: + fail_to_meet_the_condition: + other: "级别不符合条件" + report: + handle_failed: + other: "报告处理失败" + not_found: + other: "报告未找到" + tag: + not_found: + other: "标签未找到" + theme: + not_found: + other: "主题未找到" + user: + email_or_password_wrong: + other: *email_or_password_wrong + not_found: + other: "用户未找到" + suspended: + other: "用户已被暂停" + username_invalid: + other: "用户名无效" + username_duplicate: + other: "用户名已被使用" + set_avatar: + other: "头像设置错误" + report: - handle_failed: - other: "报告处理失败" - not_found: - other: "报告未找到" - tag: - not_found: - other: "标签未找到" - theme: - not_found: - other: "主题未找到" - user: - email_or_password_wrong: - other: *email_or_password_wrong - not_found: - other: "用户未找到" - suspended: - other: "用户已被暂停" - username_invalid: - other: "用户名无效" - username_duplicate: - other: "用户名已被使用" - set_avatar: - other: "头像设置错误" - -report: - spam: - name: - other: "垃圾信息" - description: - other: "此帖子是一个广告贴,或是破坏性行为。它对当前的主题无用,也不相关。" - rude: - name: - other: "粗鲁或辱骂的" - description: - other: "有理智的人都会发现此内容不适合进行尊重的讨论。" - duplicate: - name: - other: "重复信息" - description: - other: "此问题以前就有人问过,而且已经有了答案。" - not_answer: - name: - other: "不是答案" - description: - other: "此帖子是作为一个答案发布的,但它并没有试图回答这个问题。总之,它可能应该是个编辑,评论,另一个问题或者被删除。" - not_need: - name: - other: "不再需要" - description: - other: "此条评论是过时的,对话性的或与本帖无关。" - other: - name: - other: "其他原因" - description: - other: "此帖子需要工作人员关注,因为是上述所列以外的其他理由。" - -question: - close: - duplicate: + spam: name: other: "垃圾信息" + description: + other: "此帖子是一个广告贴,或是破坏性行为。它对当前的主题无用,也不相关。" + rude: + name: + other: "粗鲁或辱骂的" + description: + other: "有理智的人都会发现此内容不适合进行尊重的讨论。" + duplicate: + name: + other: "重复信息" description: other: "此问题以前就有人问过,而且已经有了答案。" - guideline: + not_answer: name: - other: "社区特定原因" + other: "不是答案" description: - other: "此问题不符合社区准则。" - multiple: + other: "此帖子是作为一个答案发布的,但它并没有试图回答这个问题。总之,它可能应该是个编辑,评论,另一个问题或者被删除。" + not_need: name: - other: "需要细节或澄清" + other: "不再需要" description: - other: "此问题目前涵盖多个问题。它应该只集中在一个问题上。" + other: "此条评论是过时的,对话性的或与本帖无关。" other: name: other: "其他原因" description: - other: "此帖子需要上述所列以外的其他理由。" + other: "此帖子需要工作人员关注,因为是上述所列以外的其他理由。" + + question: + close: + duplicate: + name: + other: "垃圾信息" + description: + other: "此问题以前就有人问过,而且已经有了答案。" + guideline: + name: + other: "社区特定原因" + description: + other: "此问题不符合社区准则。" + multiple: + name: + other: "需要细节或澄清" + description: + other: "此问题目前涵盖多个问题。它应该只集中在一个问题上。" + other: + name: + other: "其他原因" + description: + other: "此帖子需要上述所列以外的其他理由。" + + notification: + action: + update_question: + other: "更新了问题" + answer_the_question: + other: "回答了问题" + update_answer: + other: "更新了答案" + adopt_answer: + other: "接受了答案" + comment_question: + other: "评论了问题" + comment_answer: + other: "评论了答案" + reply_to_you: + other: "回复了你" + mention_you: + other: "提到了你" + your_question_is_closed: + other: "你的问题已被关闭" + your_question_was_deleted: + other: "你的问题已被删除" + your_answer_was_deleted: + other: "你的答案已被删除" + your_comment_was_deleted: + other: "你的评论已被删除" +# The following fields are used for interface presentation(Front-end) +ui: + how_to_format: + title: 如何设定文本格式 + description: >- + + pagination: + prev: 上一页 + next: 下一页 + page_title: + question: 问题 + questions: 问题 + tag: 标签 + tags: 标签 + tag_wiki: 标签 wiki + edit_tag: 编辑标签 + ask_a_question: 提问题 + edit_question: 编辑问题 + edit_answer: 编辑回答 + search: 搜索 + posts_containing: 包含 + settings: 设定 + notifications: 通知 + login: 登录 + sign_up: 注册 + account_recovery: 账号恢复 + account_activation: 账号激活 + confirm_email: 确认电子邮件 + account_suspended: 账号已封禁 + admin: 后台管理 + notifications: + title: 通知 + inbox: 收件箱 + achievement: 成就 + all_read: 全部标记为已读 + show_more: 显示更多 + suspended: + title: 账号已封禁 + until_time: '你的账号被封禁至{{ time }}。' + forever: 你的账号已被永久封禁。 + end: 违反了我们的社区准则。 + editor: + blockquote: + text: 引用 + bold: + text: 粗体 + chart: + text: 图表 + flow_chart: 流程图 + sequence_diagram: 时序图 + class_diagram: 类图 + state_diagram: 状态图 + entity_relationship_diagram: ER 图 + user_defined_diagram: User defined diagram + gantt_chart: 甘特图 + pie_chart: 饼图 + code: + text: 代码块 + add_code: 添加代码块 + form: + fields: + code: + label: 代码块 + msg: + empty: 代码块不能为空 + language: + label: 语言 (可选) + placeholder: 自动识别 + btn_cancel: 取消 + btn_confirm: 添加 + formula: + text: 公式 + options: + inline: 行内公式 + block: 公式块 + heading: + text: 标题 + options: + h1: 标题 1 + h2: 标题 2 + h3: 标题 3 + h4: 标题 4 + h5: 标题 5 + h6: 标题 6 + help: + text: 帮助 + hr: + text: 水平分割线 + image: + text: 图片 + add_image: 添加图片 + tab_image: 上传图片 + form_image: + fields: + file: + label: 图片文件 + btn: 选择图片 + msg: + empty: 请选择图片文件。 + only_image: 只能上传图片文件。 + max_size: 图片文件大小不能超过 4 MB。 + description: + label: 图片描述(可选) + tab_url: 网络图片 + form_url: + fields: + url: + label: 图片地址 + msg: + empty: 图片地址不能为空 + name: + label: 图片描述(可选) + btn_cancel: 取消 + btn_confirm: 添加 + uploading: 上传中... + indent: + text: 添加缩进 + outdent: + text: 减少缩进 + italic: + text: 斜体 + link: + text: 超链接 + add_link: 添加超链接 + form: + fields: + url: + label: 链接 + msg: + empty: 链接不能为空。 + name: + label: 链接描述(可选) + btn_cancel: 取消 + btn_confirm: 添加 + ordered_list: + text: 有编号列表 + unordered_list: + text: 无编号列表 + table: + text: 表格 + heading: 表头 + cell: 单元格 + close_modal: + title: 关闭原因是... + btn_cancel: 取消 + btn_submit: 提交 + remark: + empty: 不能为空。 + msg: + empty: 请选择一个原因。 + report_modal: + flag_title: 举报原因是... + close_title: 关闭原因是... + review_question_title: 审查问题 + review_answer_title: 审查回答 + review_comment_title: 审查评论 + btn_cancel: 取消 + btn_submit: 提交 + remark: + empty: 不能为空 + msg: + empty: 请选择一个原因。 + tag_modal: + title: 创建新标签 + form: + fields: + display_name: + label: 显示名称(别名) + msg: + empty: 不能为空 + range: 不能超过 35 个字符 + slug_name: + label: URL 固定链接 + description: '必须由 "a-z", "0-9", "+ # - ." 组成' + msg: + empty: 不能为空 + range: 不能超过 35 个字符 + character: 包含非法字符 + description: + label: 标签描述(可选) + btn_cancel: 取消 + btn_submit: 提交 + tag_info: + created_at: 创建于 + edited_at: 编辑于 + synonyms: + title: 同义词 + text: 以下标签等同于 + empty: 此标签目前没有同义词。 + btn_add: 添加同义词 + btn_edit: 编辑 + btn_save: 保存 + synonyms_text: 以下标签等同于 + delete: + title: 删除标签 + content:

不允许删除有关联问题的标签。

请先从关联的问题中删除此标签的引用。

+ content2: 确定要删除吗? + close: 关闭 + edit_tag: + title: 编辑标签 + default_reason: 编辑标签 + form: + fields: + revision: + label: 编辑历史 + display_name: + label: 名称 + slug_name: + label: URL 固定链接 + info: '必须由 "a-z", "0-9", "+ # - ." 组成' + description: + label: 描述 + edit_summary: + label: 编辑概要 + placeholder: 简单描述更改原因 (错别字、文字表达、格式等等) + btn_save_edits: 保存更改 + btn_cancel: 取消 + dates: + long_date: MM月DD日 + long_date_with_year: YYYY年MM月DD日 + long_date_with_time: 'YYYY年MM月DD日 HH:mm' + now: 刚刚 + x_seconds_ago: '{{count}} 秒前' + x_minutes_ago: '{{count}} 分钟前' + x_hours_ago: '{{count}} 小时前' + comment: + btn_add_comment: 添加评论 + reply_to: 回复 + btn_reply: 回复 + btn_edit: 编辑 + btn_delete: 删除 + btn_flag: 举报 + btn_save_edits: 保存 + btn_cancel: 取消 + show_more: 显示更多评论 + tip_question: 使用评论提问更多信息或者提出改进意见。尽量避免使用评论功能回答问题。 + tip_answer: 使用评论对回答者进行回复,或者通知回答者你已更新了问题的内容。如果要补充或者完善问题的内容,请在原问题中更改。 + edit_answer: + title: 编辑回答 + default_reason: 编辑回答 + form: + fields: + revision: + label: 编辑历史 + answer: + label: 回答内容 + edit_summary: + label: 编辑概要 + placeholder: 简单描述更改原因 (错别字、文字表达、格式等等) + btn_save_edits: 保存更改 + btn_cancel: 取消 + tags: + title: 标签 + sort_buttons: + popular: 热门 + name: 名称 + newest: 最新 + button_follow: 关注 + button_following: 已关注 + tag_label: 个问题 + search_placeholder: 通过标签名过滤 + no_description: 此标签无描述。 + more: 更多 + ask: + title: 提交新的问题 + edit_title: 编辑问题 + default_reason: 编辑问题 + similar_questions: 相似的问题 + form: + fields: + revision: + label: 编辑历史 + title: + label: 标题 + placeholder: 请详细描述你的问题 + msg: + empty: 标题不能为空 + range: 标题最多 150 个字符 + body: + label: 内容 + msg: + empty: 内容不能为空 + tags: + label: 标签 + msg: + empty: 必须选择一个标签 + answer: + label: 回答内容 + msg: + empty: 回答内容不能为空 + btn_post_question: 提交问题 + btn_save_edits: 保存更改 + answer_question: 直接发表回答 + post_question&answer: 提交问题和回答 + tag_selector: + add_btn: 添加标签 + create_btn: 创建新标签 + search_tag: 搜索标签 + hint: 选择至少一个与问题相关的标签。 + no_result: 没有匹配的标签 + header: + nav: + question: 问题 + tag: 标签 + user: 用户 + profile: 用户主页 + setting: 账号设置 + logout: 退出登录 + admin: 后台管理 + search: + placeholder: 搜索 + footer: + build_on: >- + Built on <1> Answer - the open-source software that power Q&A + communities
Made with love © 2022 Answer + upload_img: + name: 更改图片 + loading: 加载中... + pic_auth_code: + title: 验证码 + placeholder: 输入图片中的文字 + msg: + empty: 不能为空 + inactive: + first: '马上就好了!我们发送了一封激活邮件到 {{mail}}。请按照邮件中的说明激活您的帐户。' + info: 如果没有收到,请检查您的垃圾邮件文件夹。 + another: '我们向您发送了另一封激活电子邮件,地址为 {{mail}}。它可能需要几分钟才能到达;请务必检查您的垃圾邮件文件夹。' + btn_name: 重新发送激活邮件 + msg: + empty: 不能为空 + login: + page_title: 欢迎来到 Answer + info_sign: 没有帐户?<1>注册 + info_login: 已经有一个帐户?<1>登录 + forgot_pass: 忘记密码? + name: + label: 昵称 + msg: + empty: 昵称不能为空 + range: 昵称最多 30 个字符 + email: + label: 邮箱 + msg: + empty: 邮箱不能为空 + password: + label: 密码 + msg: + empty: 密码不能为空 + different: 两次输入密码不一致 + account_forgot: + page_title: 忘记密码 + btn_name: 发送恢复邮件 + send_success: '如无意外,你的邮箱 {{mail}} 将会收到一封重置密码的邮件,请根据指引重置你的密码。' + email: + label: 邮箱 + msg: + empty: 邮箱不能为空 + password_reset: + page_title: 密码重置 + btn_name: 重置我的密码 + reset_success: 你已经成功更改密码,将返回登录页面 + link_invalid: 抱歉,此密码重置链接已失效。也许是你已经重置过密码了? + to_login: 前往登录页面 + password: + label: 密码 + msg: + empty: 密码不能为空 + length: 密码长度在8-32个字符之间 + different: 两次输入密码不一致 + password_confirm: + label: 确认新密码 + settings: + page_title: 设置 + nav: + profile: 我的资料 + notification: 通知 + account: 账号 + interface: 界面 + profile: + btn_name: 保存更改 + display_name: + label: 昵称 + msg: 昵称不能为空 + msg_range: 昵称不能超过 30 个字符 + username: + label: 用户名 + caption: 用户之间可以通过 "@用户名" 进行交互。 + msg: 用户名不能为空 + msg_range: 用户名不能超过 30 个字符 + character: '用户名只能由 "a-z", "0-9", " - . _" 组成' + avatar: + label: 头像 + text: 您可以上传图片作为头像,也可以 <1>重置 为 + bio: + label: 关于我 (可选) + website: + label: 网站 (可选) + placeholder: 'https://example.com' + msg: 格式不正确 + location: + label: 位置 (可选) + placeholder: '城市, 国家' + notification: + email: + label: 邮件通知 + radio: 你的提问有新的回答,评论,和其他 + account: + change_email_btn: 更改邮箱 + change_pass_btn: 更改密码 + change_email_info: 邮件已发送。请根据指引完成验证。 + email: + label: 邮箱 + msg: 邮箱不能为空 + password_title: 密码 + current_pass: + label: 当前密码 + msg: + empty: 当前密码不能为空 + length: 密码长度必须在 8 至 32 之间 + different: 两次输入的密码不匹配 + new_pass: + label: 新密码 + pass_confirm: + label: 确认新密码 + interface: + lang: + label: 界面语言 + text: 设置用户界面语言,在刷新页面后生效。 + toast: + update: 更新成功 + update_password: 更改密码成功。 + flag_success: 感谢您的标记,我们会尽快处理。 + related_question: + title: 相关问题 + btn: 我要提问 + answers: 个回答 + question_detail: + Asked: 提问于 + asked: 提问于 + update: 修改于 + edit: 最后编辑于 + Views: 阅读次数 + Follow: 关注此问题 + Following: 已关注 + answered: 回答于 + closed_in: 关闭于 + show_exist: 查看相关问题。 + answers: + title: 个回答 + score: 评分 + newest: 最新 + btn_accept: 采纳 + btn_accepted: 已被采纳 + write_answer: + title: 你的回答 + btn_name: 提交你的回答 + confirm_title: 继续回答 + continue: 继续 + confirm_info:

您确定要提交一个新的回答吗?

您可以直接编辑和改善您之前的回答的。

+ empty: 回答内容不能为空。 + delete: + title: 删除 + question: >- + 我们不建议删除有回答的帖子。因为这样做会使得后来的读者无法从该问题中获得帮助。

如果删除过多有回答的帖子,你的账号将会被禁止提问。你确定要删除吗? + answer_accepted: >- +

我们不建议删除被采纳的回答。因为这样做会使得后来的读者无法从该回答中获得帮助。

如果删除过多被采纳的回答,你的账号将会被禁止回答任何提问。你确定要删除吗? + other: 你确定要删除? + tip_question_deleted: 此问题已被删除 + tip_answer_deleted: 此回答已被删除 + btns: + confirm: 确认 + cancel: 取消 + save: 保存 + delete: 删除 + login: 登录 + signup: 注册 + logout: 退出登录 + verify: 验证 + add_question: 我要提问 + search: + title: 搜索结果 + keywords: 关键词 + options: 选项 + follow: 关注 + following: 已关注 + counts: '{{count}} 个结果' + more: 更多 + sort_btns: + relevance: 相关性 + newest: 最新的 + active: 活跃的 + score: 评分 + tips: + title: 高级搜索提示 + tag: '<1>[tag] 在指定标签中搜索' + user: '<1>user:username 根据作者搜索' + answer: '<1>answers:0 搜索未回答的问题' + score: '<1>score:3 评分 3 分或以上' + question: '<1>is:question 只搜索问题' + is_answer: '<1>is:answer 只搜索回答' + empty: 找不到任何相关的内容。
请尝试其他关键字,或者减少查找内容的长度。 + share: + name: 分享 + copy: 复制链接 + via: 分享在... + copied: 已复制 + facebook: 分享到 Facebook + twitter: 分享到 Twitter + cannot_vote_for_self: 不能给自己投票 + modal_confirm: + title: 发生错误... + account_result: + page_title: 欢迎来到 Answer + success: 你的账号已通过验证,即将返回首页。 + link: 返回首页 + invalid: 抱歉,此验证链接已失效。也许是你的账号已经通过验证了? + confirm_new_email: 你的电子邮箱已更新 + confirm_new_email_invalid: 抱歉,此验证链接已失效。也许是你的邮箱已经成功更改了? + question: + following_tags: 已关注的标签 + edit: 编辑 + save: 保存 + follow_tag_tip: 按照标签整理您的问题列表。 + hot_questions: 热点问题 + all_questions: 全部问题 + x_questions: '{{ count }} 个问题' + x_answers: '{{ count }} 个回答' + questions: 个问题 + answers: 回答 + newest: 最新 + active: 活跃 + frequent: 浏览量 + score: 评分 + unanswered: 未回答 + modified: 修改于 + answered: 回答于 + asked: 提问于 + closed: 已关闭 + follow_a_tag: 关注一个标签 + more: 更多 + personal: + overview: 概览 + answers: 回答 + answer: 回答 + questions: 问题 + question: 问题 + bookmarks: 收藏 + reputation: 声望 + comments: 评论 + votes: 得票 + newest: 最新 + score: 评分 + edit_profile: 编辑我的资料 + visited_x_days: 'Visited {{ count }} days' + viewed: Viewed + joined: 加入于 + last_login: 上次登录 + about_me: 关于我 + about_me_empty: '// Hello, World !' + top_answers: 热门回答 + top_questions: 热门问题 + stats: 状态 + list_empty: 没有找到相关的内容。
试试看其他标签? + accepted: 已采纳 + answered: 回答于 + asked: 提问于 + upvote: 赞同 + downvote: 反对 + mod_short: 管理员 + mod_long: 管理员 + x_reputation: 声望 + x_votes: 得票 + x_answers: 个回答 + x_questions: 个问题 + page_404: + description: 页面不存在 + back_home: 回到主页 + page_50X: + description: 服务器遇到了一个错误,无法完成你的请求。 + back_home: 回到主页 + admin: + admin_header: + title: 后台管理 + nav_menus: + dashboard: 后台管理 + contents: 内容管理 + questions: 问题 + answers: 回答 + users: 用户管理 + flags: 举报管理 + settings: 站点设置 + general: 一般 + interface: 界面 + smtp: SMTP + dashboard: + title: 后台管理 + welcome: 欢迎来到 Answer 后台管理! + version: 版本 + flags: + title: 举报 + pending: 等待处理 + completed: 已完成 + flagged: 被举报内容 + created: 创建于 + action: 操作 + review: 审查 + change_modal: + title: 更改用户状态为... + btn_cancel: 取消 + btn_submit: 提交 + normal_name: 正常 + normal_description: 正常状态的用户可以提问和回答。 + suspended_name: 封禁 + suspended_description: 被封禁的用户将无法登录。 + deleted_name: 删除 + deleted_description: 删除用户的个人信息,认证等等。 + inactive_name: 不活跃 + inactive_description: 不活跃的用户必须重新验证邮箱。 + confirm_title: 删除此用户 + confirm_content: 确定要删除此用户?此操作无法撤销! + confirm_btn: 删除 + msg: + empty: 请选择一个原因 + status_modal: + title: '更改 {{ type }} 状态为...' + normal_name: 正常 + normal_description: 所有用户都可以访问 + closed_name: 关闭 + closed_description: 不能回答,但仍然可以编辑、投票和评论。 + deleted_name: 删除 + deleted_description: 所有获得/损失的声望将会恢复。 + btn_cancel: 取消 + btn_submit: 提交 + btn_next: 下一步 + users: + title: 用户 + name: 名称 + email: 邮箱 + reputation: 声望 + created_at: 创建时间 + delete_at: 删除时间 + suspend_at: 封禁时间 + status: 状态 + action: 操作 + change: 更改 + all: 全部 + inactive: 不活跃 + suspended: 已封禁 + deleted: 已删除 + normal: 正常 + questions: + page_title: 问题 + normal: 正常 + closed: 已关闭 + deleted: 已删除 + post: 标题 + votes: 得票数 + answers: 回答数 + created: 创建于 + status: 状态 + action: 操作 + change: 更改 + answers: + page_title: 回答 + normal: 正常 + deleted: 已删除 + post: 标题 + votes: 得票数 + created: 创建于 + status: 状态 + action: 操作 + change: 更改 + general: + page_title: 一般 + name: + label: 站点名称 + msg: 不能为空 + text: 站点的名称,作为站点的标题(HTML 的 title 标签)。 + short_description: + label: 简短的站点标语 (可选) + msg: 不能为空 + text: 简短的标语,作为网站主页的标题(HTML 的 title 标签)。 + description: + label: 网站描述 (可选) + msg: 不能为空 + text: 使用一句话描述本站,作为网站的描述(HTML 的 meta 标签)。 + interface: + page_title: 界面 + logo: + label: Logo (可选) + msg: 不能为空 + text: 可以上传图片,或者<1>重置为站点标题。 + theme: + label: 主题 + msg: 不能为空 + text: 选择一个主题 + language: + label: 界面语言 + msg: 不能为空 + text: 设置用户界面语言,在刷新页面后生效。 + smtp: + page_title: SMTP + from_email: + label: 发件人地址 + msg: 不能为空 + text: 用于发送邮件的地址。 + from_name: + label: 发件人名称 + msg: 不能为空 + text: 发件人的名称 + smtp_host: + label: SMTP 主机 + msg: 不能为空 + text: 邮件服务器 + encryption: + label: 加密 + msg: 不能为空 + text: 对于大多数服务器而言,SSL 是推荐开启的。 + ssl: SSL + none: 无加密 + smtp_port: + label: SMTP 端口 + msg: SMTP 端口必须在 1 ~ 65535 之间。 + text: 邮件服务器的端口号。 + smtp_username: + label: SMTP 用户名 + msg: 不能为空 + smtp_password: + label: SMTP 密码 + msg: 不能为空 + test_email_recipient: + label: 测试邮件收件人 + text: 提供用于接收测试邮件的邮箱地址。 + msg: 地址无效 + smtp_authentication: + label: SMTP 认证 + msg: 不能为空 + 'yes': 是 + 'no': 否 -notification: - action: - update_question: - other: "更新了问题" - answer_the_question: - other: "回答了问题" - update_answer: - other: "更新了答案" - adopt_answer: - other: "接受了答案" - comment_question: - other: "评论了问题" - comment_answer: - other: "评论了答案" - reply_to_you: - other: "回复了你" - mention_you: - other: "提到了你" - your_question_is_closed: - other: "你的问题已被关闭" - your_question_was_deleted: - other: "你的问题已被删除" - your_answer_was_deleted: - other: "你的答案已被删除" - your_comment_was_deleted: - other: "你的评论已被删除" diff --git a/internal/base/conf/conf.go b/internal/base/conf/conf.go index 97859ac0..18a841d9 100644 --- a/internal/base/conf/conf.go +++ b/internal/base/conf/conf.go @@ -1,30 +1,64 @@ package conf import ( + "bytes" + "path/filepath" + "github.com/answerdev/answer/internal/base/data" "github.com/answerdev/answer/internal/base/server" "github.com/answerdev/answer/internal/base/translator" + "github.com/answerdev/answer/internal/cli" "github.com/answerdev/answer/internal/router" "github.com/answerdev/answer/internal/service/service_config" + "github.com/answerdev/answer/pkg/writer" + "github.com/segmentfault/pacman/contrib/conf/viper" + "gopkg.in/yaml.v3" ) // AllConfig all config type AllConfig struct { - Debug bool `json:"debug" mapstructure:"debug"` - Data *Data `json:"data" mapstructure:"data"` - Server *Server `json:"server" mapstructure:"server"` - I18n *translator.I18n `json:"i18n" mapstructure:"i18n"` - Swaggerui *router.SwaggerConfig `json:"swaggerui" mapstructure:"swaggerui"` - ServiceConfig *service_config.ServiceConfig `json:"service_config" mapstructure:"service_config"` + Debug bool `json:"debug" mapstructure:"debug" yaml:"debug"` + Server *Server `json:"server" mapstructure:"server" yaml:"server"` + Data *Data `json:"data" mapstructure:"data" yaml:"data"` + I18n *translator.I18n `json:"i18n" mapstructure:"i18n" yaml:"i18n"` + ServiceConfig *service_config.ServiceConfig `json:"service_config" mapstructure:"service_config" yaml:"service_config"` + Swaggerui *router.SwaggerConfig `json:"swaggerui" mapstructure:"swaggerui" yaml:"swaggerui"` } // Server server config type Server struct { - HTTP *server.HTTP `json:"http" mapstructure:"http"` + HTTP *server.HTTP `json:"http" mapstructure:"http" yaml:"http"` } // Data data config type Data struct { - Database *data.Database `json:"database" mapstructure:"database"` - Cache *data.CacheConf `json:"cache" mapstructure:"cache"` + Database *data.Database `json:"database" mapstructure:"database" yaml:"database"` + Cache *data.CacheConf `json:"cache" mapstructure:"cache" yaml:"cache"` +} + +// ReadConfig read config +func ReadConfig(configFilePath string) (c *AllConfig, err error) { + if len(configFilePath) == 0 { + configFilePath = filepath.Join(cli.ConfigFileDir, cli.DefaultConfigFileName) + } + c = &AllConfig{} + config, err := viper.NewWithPath(configFilePath) + if err != nil { + return nil, err + } + if err = config.Parse(&c); err != nil { + return nil, err + } + return c, nil +} + +// RewriteConfig rewrite config file path +func RewriteConfig(configFilePath string, allConfig *AllConfig) error { + buf := bytes.Buffer{} + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(allConfig); err != nil { + return err + } + return writer.ReplaceFile(configFilePath, buf.String()) } diff --git a/internal/base/constant/constant.go b/internal/base/constant/constant.go index 7722adf8..e41d50ea 100644 --- a/internal/base/constant/constant.go +++ b/internal/base/constant/constant.go @@ -27,6 +27,8 @@ const ( // object TagID AnswerList // key equal database's table name var ( + Version string = "" + ObjectTypeStrMapping = map[string]int{ QuestionObjectType: 1, AnswerObjectType: 2, @@ -47,3 +49,8 @@ var ( 8: ReportObjectType, } ) + +const ( + SiteTypeGeneral = "general" + SiteTypeInterface = "interface" +) diff --git a/internal/base/data/config.go b/internal/base/data/config.go index 6bc4ec74..cd48547c 100644 --- a/internal/base/data/config.go +++ b/internal/base/data/config.go @@ -2,14 +2,14 @@ package data // Database database config type Database struct { - Driver string `json:"driver" mapstructure:"driver"` - Connection string `json:"connection" mapstructure:"connection"` - ConnMaxLifeTime int `json:"conn_max_life_time" mapstructure:"conn_max_life_time"` - MaxOpenConn int `json:"max_open_conn" mapstructure:"max_open_conn"` - MaxIdleConn int `json:"max_idle_conn" mapstructure:"max_idle_conn"` + Driver string `json:"driver" mapstructure:"driver" yaml:"driver"` + Connection string `json:"connection" mapstructure:"connection" yaml:"connection"` + ConnMaxLifeTime int `json:"conn_max_life_time" mapstructure:"conn_max_life_time" yaml:"conn_max_life_time,omitempty"` + MaxOpenConn int `json:"max_open_conn" mapstructure:"max_open_conn" yaml:"max_open_conn,omitempty"` + MaxIdleConn int `json:"max_idle_conn" mapstructure:"max_idle_conn" yaml:"max_idle_conn,omitempty"` } // CacheConf cache type CacheConf struct { - FilePath string `json:"file_path" mapstructure:"file_path"` + FilePath string `json:"file_path" mapstructure:"file_path" yaml:"file_path"` } diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index abe1bd23..6765a883 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -38,4 +38,9 @@ const ( LangNotFound = "error.lang.not_found" ReportHandleFailed = "error.report.handle_failed" ReportNotFound = "error.report.not_found" + ReadConfigFailed = "error.config.read_config_failed" + DatabaseConnectionFailed = "error.database.connection_failed" + InstallCreateTableFailed = "error.database.create_table_failed" + InstallConfigFailed = "error.install.create_config_failed" + SiteInfoNotFound = "error.site_info.not_found" ) diff --git a/internal/base/translator/config.go b/internal/base/translator/config.go index a00b5218..55a69203 100644 --- a/internal/base/translator/config.go +++ b/internal/base/translator/config.go @@ -2,5 +2,5 @@ package translator // I18n i18n config type I18n struct { - BundleDir string `json:"bundle_dir" mapstructure:"bundle_dir"` + BundleDir string `json:"bundle_dir" mapstructure:"bundle_dir" yaml:"bundle_dir"` } diff --git a/internal/base/translator/provider.go b/internal/base/translator/provider.go index 0c0bcac6..527f9137 100644 --- a/internal/base/translator/provider.go +++ b/internal/base/translator/provider.go @@ -1,17 +1,100 @@ package translator import ( + "fmt" + "os" + "path/filepath" + "github.com/google/wire" myTran "github.com/segmentfault/pacman/contrib/i18n" "github.com/segmentfault/pacman/i18n" + "gopkg.in/yaml.v3" ) // 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"` +} + +// DefaultLangOption default language option. If user config the language is default, the language option is admin choose. +const DefaultLangOption = "Default" + +var ( + // LanguageOptions language + LanguageOptions []*LangOption +) + // NewTranslator new a translator func NewTranslator(c *I18n) (tr i18n.Translator, err error) { - GlobalTrans, err = myTran.NewTranslator(c.BundleDir) + entries, err := os.ReadDir(c.BundleDir) + if err != nil { + return nil, err + } + + // read the Bundle resources file from entries + for _, file := range entries { + // ignore directory + if file.IsDir() { + continue + } + // ignore non-YAML file + if filepath.Ext(file.Name()) != ".yaml" && file.Name() != "i18n.yaml" { + continue + } + buf, err := os.ReadFile(filepath.Join(c.BundleDir, file.Name())) + if err != nil { + return nil, fmt.Errorf("read file failed: %s %s", file.Name(), err) + } + + // only parse the backend translation + translation := struct { + Content map[string]interface{} `yaml:"backend"` + }{} + if err = yaml.Unmarshal(buf, &translation); err != nil { + return nil, err + } + content, err := yaml.Marshal(translation.Content) + if err != nil { + return nil, fmt.Errorf("marshal translation content failed: %s %s", file.Name(), err) + } + + // add translator use backend translation + if err = myTran.AddTranslator(content, file.Name()); err != nil { + return nil, fmt.Errorf("add translator failed: %s %s", file.Name(), err) + } + } + GlobalTrans = myTran.GlobalTrans + + 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 `yaml:"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 { + if lang == DefaultLangOption { + return true + } + for _, option := range LanguageOptions { + if option.Value == lang { + return true + } + } + return false +} diff --git a/internal/base/validator/validator.go b/internal/base/validator/validator.go index 4a0f22b1..b089ccf0 100644 --- a/internal/base/validator/validator.go +++ b/internal/base/validator/validator.go @@ -3,6 +3,7 @@ package validator import ( "errors" "reflect" + "strings" "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/base/translator" @@ -97,9 +98,19 @@ func (m *MyValidator) Check(value interface{}) (errField *ErrorField, err error) for _, fieldError := range valErrors { errField = &ErrorField{ - Key: translator.GlobalTrans.Tr(m.Lang, fieldError.Field()), + Key: fieldError.Field(), Value: fieldError.Translate(m.Tran), } + + // get original tag name from value for set err field key. + structNamespace := fieldError.StructNamespace() + _, fieldName, found := strings.Cut(structNamespace, ".") + if found { + originalTag := getObjectTagByFieldName(value, fieldName) + if len(originalTag) > 0 { + errField.Key = originalTag + } + } return errField, myErrors.BadRequest(reason.RequestFormatError).WithMsg(fieldError.Translate(m.Tran)) } } @@ -117,3 +128,24 @@ func (m *MyValidator) Check(value interface{}) (errField *ErrorField, err error) type Checker interface { Check() (errField *ErrorField, err error) } + +func getObjectTagByFieldName(obj interface{}, fieldName string) (tag string) { + defer func() { + if err := recover(); err != nil { + log.Error(err) + } + }() + + objT := reflect.TypeOf(obj) + objT = objT.Elem() + + structField, exists := objT.FieldByName(fieldName) + if !exists { + return "" + } + tag = structField.Tag.Get("json") + if len(tag) == 0 { + return structField.Tag.Get("form") + } + return tag +} diff --git a/internal/cli/install.go b/internal/cli/install.go index 8c8ee64f..1f759b7b 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -1,59 +1,71 @@ package cli import ( - "bufio" "fmt" - "os" "path/filepath" "github.com/answerdev/answer/configs" "github.com/answerdev/answer/i18n" "github.com/answerdev/answer/pkg/dir" + "github.com/answerdev/answer/pkg/writer" ) const ( DefaultConfigFileName = "config.yaml" + DefaultCacheFileName = "cache.db" ) var ( - ConfigFilePath = "/conf/" - UploadFilePath = "/upfiles/" + ConfigFileDir = "/conf/" + UploadFilePath = "/uploads/" I18nPath = "/i18n/" + CacheDir = "/cache/" ) +// GetConfigFilePath get config file path +func GetConfigFilePath() string { + return filepath.Join(ConfigFileDir, DefaultConfigFileName) +} + +func FormatAllPath(dataDirPath string) { + ConfigFileDir = filepath.Join(dataDirPath, ConfigFileDir) + UploadFilePath = filepath.Join(dataDirPath, UploadFilePath) + I18nPath = filepath.Join(dataDirPath, I18nPath) + CacheDir = filepath.Join(dataDirPath, CacheDir) +} + // InstallAllInitialEnvironment install all initial environment func InstallAllInitialEnvironment(dataDirPath string) { - ConfigFilePath = filepath.Join(dataDirPath, ConfigFilePath) - UploadFilePath = filepath.Join(dataDirPath, UploadFilePath) - I18nPath = filepath.Join(dataDirPath, I18nPath) - - installConfigFile() + FormatAllPath(dataDirPath) installUploadDir() installI18nBundle() fmt.Println("install all initial environment done") } -func installConfigFile() { - fmt.Println("[config-file] try to install...") - defaultConfigFile := filepath.Join(ConfigFilePath, DefaultConfigFileName) +func InstallConfigFile(configFilePath string) error { + if len(configFilePath) == 0 { + configFilePath = filepath.Join(ConfigFileDir, DefaultConfigFileName) + } + fmt.Println("[config-file] try to create at ", configFilePath) // if config file already exists do nothing. - if CheckConfigFile(defaultConfigFile) { - fmt.Printf("[config-file] %s already exists\n", defaultConfigFile) - return + if CheckConfigFile(configFilePath) { + fmt.Printf("[config-file] %s already exists\n", configFilePath) + return nil } - if err := dir.CreateDirIfNotExist(ConfigFilePath); err != nil { + if err := dir.CreateDirIfNotExist(ConfigFileDir); err != nil { fmt.Printf("[config-file] create directory fail %s\n", err.Error()) - return + return fmt.Errorf("create directory fail %s", err.Error()) } - fmt.Printf("[config-file] create directory success, config file is %s\n", defaultConfigFile) + fmt.Printf("[config-file] create directory success, config file is %s\n", configFilePath) - if err := writerFile(defaultConfigFile, string(configs.Config)); err != nil { + if err := writer.WriteFile(configFilePath, string(configs.Config)); err != nil { fmt.Printf("[config-file] install fail %s\n", err.Error()) - return + return fmt.Errorf("write file failed %s", err) } fmt.Printf("[config-file] install success\n") + return nil } func installUploadDir() { @@ -85,7 +97,7 @@ func installI18nBundle() { continue } fmt.Printf("[i18n] install %s bundle...\n", item.Name()) - err = writerFile(path, string(content)) + err = writer.WriteFile(path, string(content)) if err != nil { fmt.Printf("[i18n] install %s bundle fail: %s\n", item.Name(), err.Error()) } else { @@ -93,21 +105,3 @@ func installI18nBundle() { } } } - -func writerFile(filePath, content string) error { - file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0o666) - if err != nil { - return err - } - defer func() { - _ = file.Close() - }() - writer := bufio.NewWriter(file) - if _, err := writer.WriteString(content); err != nil { - return err - } - if err := writer.Flush(); err != nil { - return err - } - return nil -} diff --git a/internal/cli/install_check.go b/internal/cli/install_check.go index fb05e33f..6cb25987 100644 --- a/internal/cli/install_check.go +++ b/internal/cli/install_check.go @@ -1,7 +1,10 @@ package cli import ( + "fmt" + "github.com/answerdev/answer/internal/base/data" + "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/pkg/dir" ) @@ -13,12 +16,40 @@ func CheckUploadDir() bool { return dir.CheckDirExist(UploadFilePath) } -func CheckDB(dataConf *data.Database) bool { +// CheckDBConnection check database whether the connection is normal +func CheckDBConnection(dataConf *data.Database) bool { db, err := data.NewDB(false, dataConf) if err != nil { + fmt.Printf("connection database failed: %s\n", err) return false } if err = db.Ping(); err != nil { + fmt.Printf("connection ping database failed: %s\n", err) + return false + } + + return true +} + +// CheckDBTableExist check database whether the table is already exists +func CheckDBTableExist(dataConf *data.Database) bool { + db, err := data.NewDB(false, dataConf) + if err != nil { + fmt.Printf("connection database failed: %s\n", err) + return false + } + if err = db.Ping(); err != nil { + fmt.Printf("connection ping database failed: %s\n", err) + return false + } + + exist, err := db.IsTableExist(&entity.Version{}) + if err != nil { + fmt.Printf("check table exist failed: %s\n", err) + return false + } + if !exist { + fmt.Printf("check table not exist\n") return false } return true diff --git a/internal/controller/answer_controller.go b/internal/controller/answer_controller.go index 38ca3624..6db77008 100644 --- a/internal/controller/answer_controller.go +++ b/internal/controller/answer_controller.go @@ -9,6 +9,7 @@ import ( "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/schema" "github.com/answerdev/answer/internal/service" + "github.com/answerdev/answer/internal/service/dashboard" "github.com/answerdev/answer/internal/service/rank" "github.com/gin-gonic/gin" "github.com/segmentfault/pacman/errors" @@ -16,13 +17,21 @@ import ( // AnswerController answer controller type AnswerController struct { - answerService *service.AnswerService - rankService *rank.RankService + answerService *service.AnswerService + rankService *rank.RankService + dashboardService *dashboard.DashboardService } // NewAnswerController new controller -func NewAnswerController(answerService *service.AnswerService, rankService *rank.RankService) *AnswerController { - return &AnswerController{answerService: answerService, rankService: rankService} +func NewAnswerController(answerService *service.AnswerService, + rankService *rank.RankService, + dashboardService *dashboard.DashboardService, +) *AnswerController { + return &AnswerController{ + answerService: answerService, + rankService: rankService, + dashboardService: dashboardService, + } } // RemoveAnswer delete answer diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 65776043..4a76acf7 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -20,4 +20,5 @@ var ProviderSetController = wire.NewSet( NewReasonController, NewNotificationController, NewSiteinfoController, + NewDashboardController, ) diff --git a/internal/controller/cron_controller.go b/internal/controller/cron_controller.go new file mode 100644 index 00000000..b0b429f8 --- /dev/null +++ b/internal/controller/cron_controller.go @@ -0,0 +1 @@ +package controller diff --git a/internal/controller/dashboard_controller.go b/internal/controller/dashboard_controller.go new file mode 100644 index 00000000..a525adeb --- /dev/null +++ b/internal/controller/dashboard_controller.go @@ -0,0 +1,36 @@ +package controller + +import ( + "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/service/dashboard" + "github.com/gin-gonic/gin" +) + +type DashboardController struct { + dashboardService *dashboard.DashboardService +} + +// NewDashboardController new controller +func NewDashboardController( + dashboardService *dashboard.DashboardService, +) *DashboardController { + return &DashboardController{ + dashboardService: dashboardService, + } +} + +// DashboardInfo godoc +// @Summary DashboardInfo +// @Description DashboardInfo +// @Tags admin +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Router /answer/admin/api/dashboard [get] +// @Success 200 {object} handler.RespBody +func (ac *DashboardController) DashboardInfo(ctx *gin.Context) { + info, err := ac.dashboardService.StatisticalByCache(ctx) + handler.HandleResponse(ctx, err, gin.H{ + "info": info, + }) +} diff --git a/internal/controller/lang_controller.go b/internal/controller/lang_controller.go index a689fffc..b48080ee 100644 --- a/internal/controller/lang_controller.go +++ b/internal/controller/lang_controller.go @@ -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/siteinfo_common" "github.com/gin-gonic/gin" "github.com/segmentfault/pacman/i18n" ) type LangController struct { - translator i18n.Translator + translator i18n.Translator + siteInfoService *siteinfo_common.SiteInfoCommonService } // NewLangController new language controller. -func NewLangController(tr i18n.Translator) *LangController { - return &LangController{translator: tr} +func NewLangController(tr i18n.Translator, siteInfoService *siteinfo_common.SiteInfoCommonService) *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: translator.DefaultLangOption, Value: translator.DefaultLangOption}, + } + options = append(defaultOption, options...) + } + handler.HandleResponse(ctx, nil, options) } diff --git a/internal/controller/siteinfo_controller.go b/internal/controller/siteinfo_controller.go index abcbc178..5f547eb7 100644 --- a/internal/controller/siteinfo_controller.go +++ b/internal/controller/siteinfo_controller.go @@ -3,45 +3,36 @@ package controller import ( "github.com/answerdev/answer/internal/base/handler" "github.com/answerdev/answer/internal/schema" - "github.com/answerdev/answer/internal/service" + "github.com/answerdev/answer/internal/service/siteinfo_common" "github.com/gin-gonic/gin" ) type SiteinfoController struct { - siteInfoService *service.SiteInfoService + siteInfoService *siteinfo_common.SiteInfoCommonService } // NewSiteinfoController new siteinfo controller. -func NewSiteinfoController(siteInfoService *service.SiteInfoService) *SiteinfoController { +func NewSiteinfoController(siteInfoService *siteinfo_common.SiteInfoCommonService) *SiteinfoController { return &SiteinfoController{ siteInfoService: siteInfoService, } } -// GetInfo godoc -// @Summary Get siteinfo -// @Description Get siteinfo +// GetSiteInfo get site info +// @Summary get site info +// @Description get site info // @Tags site // @Produce json // @Success 200 {object} handler.RespBody{data=schema.SiteGeneralResp} // @Router /answer/api/v1/siteinfo [get] -func (sc *SiteinfoController) GetInfo(ctx *gin.Context) { - var ( - resp = &schema.SiteInfoResp{} - general schema.SiteGeneralResp - face schema.SiteInterfaceResp - err error - ) - - general, err = sc.siteInfoService.GetSiteGeneral(ctx) - resp.General = &general +func (sc *SiteinfoController) GetSiteInfo(ctx *gin.Context) { + var err error + resp := &schema.SiteInfoResp{} + resp.General, err = sc.siteInfoService.GetSiteGeneral(ctx) if err != nil { handler.HandleResponse(ctx, err, resp) return } - - face, err = sc.siteInfoService.GetSiteInterface(ctx) - resp.Face = &face - + resp.Face, err = sc.siteInfoService.GetSiteInterface(ctx) handler.HandleResponse(ctx, err, resp) } diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 495b1659..92d491fe 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -89,22 +89,6 @@ func (uc *UserController) GetOtherUserInfoByUsername(ctx *gin.Context) { handler.HandleResponse(ctx, err, resp) } -// GetUserStatus get user status info -// @Summary get user status info -// @Description get user status info -// @Tags User -// @Accept json -// @Produce json -// @Security ApiKeyAuth -// @Success 200 {object} handler.RespBody{data=schema.GetUserResp} -// @Router /answer/api/v1/user/status [get] -func (uc *UserController) GetUserStatus(ctx *gin.Context) { - userID := middleware.GetLoginUserIDFromContext(ctx) - token := middleware.ExtractToken(ctx) - resp, err := uc.userService.GetUserStatus(ctx, userID, token) - handler.HandleResponse(ctx, err, resp) -} - // UserEmailLogin godoc // @Summary UserEmailLogin // @Description UserEmailLogin @@ -373,6 +357,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 @@ -490,6 +495,10 @@ func (uc *UserController) UserChangeEmailSendCode(ctx *gin.Context) { req.UserID = middleware.GetLoginUserIDFromContext(ctx) // If the user is not logged in, the api cannot be used. // If the user email is not verified, that also can use this api to modify the email. + if len(req.UserID) == 0 { + handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) + return + } captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, schema.ActionRecordTypeEmail, ctx.ClientIP(), req.CaptchaID, req.CaptchaCode) if !captchaPass { @@ -501,13 +510,15 @@ func (uc *UserController) UserChangeEmailSendCode(ctx *gin.Context) { handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), resp) return } - - if len(req.UserID) == 0 { - handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) + _, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeEmail, ctx.ClientIP()) + resp, err := uc.userService.UserChangeEmailSendCode(ctx, req) + if err != nil { + if resp != nil { + resp.Value = translator.GlobalTrans.Tr(handler.GetLang(ctx), resp.Value) + } + handler.HandleResponse(ctx, err, resp) return } - _, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeEmail, ctx.ClientIP()) - err := uc.userService.UserChangeEmailSendCode(ctx, req) handler.HandleResponse(ctx, err, nil) } diff --git a/internal/controller_backyard/siteinfo_controller.go b/internal/controller_backyard/siteinfo_controller.go index 30bfd1bf..3a554c22 100644 --- a/internal/controller_backyard/siteinfo_controller.go +++ b/internal/controller_backyard/siteinfo_controller.go @@ -3,24 +3,24 @@ package controller_backyard import ( "github.com/answerdev/answer/internal/base/handler" "github.com/answerdev/answer/internal/schema" - "github.com/answerdev/answer/internal/service" + "github.com/answerdev/answer/internal/service/siteinfo" "github.com/gin-gonic/gin" ) type SiteInfoController struct { - siteInfoService *service.SiteInfoService + siteInfoService *siteinfo.SiteInfoService } // NewSiteInfoController new siteinfo controller. -func NewSiteInfoController(siteInfoService *service.SiteInfoService) *SiteInfoController { +func NewSiteInfoController(siteInfoService *siteinfo.SiteInfoService) *SiteInfoController { return &SiteInfoController{ siteInfoService: siteInfoService, } } -// GetGeneral godoc -// @Summary Get siteinfo general -// @Description Get siteinfo general +// GetGeneral get site general information +// @Summary get site general information +// @Description get site general information // @Security ApiKeyAuth // @Tags admin // @Produce json @@ -31,23 +31,22 @@ func (sc *SiteInfoController) GetGeneral(ctx *gin.Context) { handler.HandleResponse(ctx, err, resp) } -// GetInterface godoc -// @Summary Get siteinfo interface -// @Description Get siteinfo interface +// GetInterface get site interface +// @Summary get site interface +// @Description get site interface // @Security ApiKeyAuth // @Tags admin // @Produce json // @Success 200 {object} handler.RespBody{data=schema.SiteInterfaceResp} // @Router /answer/admin/api/siteinfo/interface [get] -// @Param data body schema.AddCommentReq true "general" func (sc *SiteInfoController) GetInterface(ctx *gin.Context) { resp, err := sc.siteInfoService.GetSiteInterface(ctx) handler.HandleResponse(ctx, err, resp) } -// UpdateGeneral godoc -// @Summary Get siteinfo interface -// @Description Get siteinfo interface +// UpdateGeneral update site general information +// @Summary update site general information +// @Description update site general information // @Security ApiKeyAuth // @Tags admin // @Produce json @@ -63,9 +62,9 @@ func (sc *SiteInfoController) UpdateGeneral(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) } -// UpdateInterface godoc -// @Summary Get siteinfo interface -// @Description Get siteinfo interface +// UpdateInterface update site interface +// @Summary update site info interface +// @Description update site info interface // @Security ApiKeyAuth // @Tags admin // @Produce json diff --git a/internal/entity/user_entity.go b/internal/entity/user_entity.go index 7ab239a7..5d615482 100644 --- a/internal/entity/user_entity.go +++ b/internal/entity/user_entity.go @@ -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 diff --git a/internal/install/install_controller.go b/internal/install/install_controller.go new file mode 100644 index 00000000..d7f8ebac --- /dev/null +++ b/internal/install/install_controller.go @@ -0,0 +1,191 @@ +package install + +import ( + "os" + "path/filepath" + "time" + + "github.com/answerdev/answer/configs" + "github.com/answerdev/answer/internal/base/conf" + "github.com/answerdev/answer/internal/base/data" + "github.com/answerdev/answer/internal/base/handler" + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/internal/base/translator" + "github.com/answerdev/answer/internal/cli" + "github.com/answerdev/answer/internal/migrations" + "github.com/answerdev/answer/internal/schema" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +// LangOptions get installation language options +// @Summary get installation language options +// @Description get installation language options +// @Tags Lang +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]*translator.LangOption} +// @Router /installation/language/options [get] +func LangOptions(ctx *gin.Context) { + handler.HandleResponse(ctx, nil, translator.LanguageOptions) +} + +// CheckConfigFile check config file if exist when installation +// @Summary check config file if exist when installation +// @Description check config file if exist when installation +// @Tags installation +// @Accept json +// @Produce json +// @Success 200 {object} handler.RespBody{data=install.CheckConfigFileResp{}} +// @Router /installation/config-file/check [post] +func CheckConfigFile(ctx *gin.Context) { + resp := &CheckConfigFileResp{} + resp.ConfigFileExist = cli.CheckConfigFile(confPath) + if !resp.ConfigFileExist { + handler.HandleResponse(ctx, nil, resp) + return + } + allConfig, err := conf.ReadConfig(confPath) + if err != nil { + log.Error(err) + err = errors.BadRequest(reason.ReadConfigFailed) + handler.HandleResponse(ctx, err, nil) + return + } + resp.DBConnectionSuccess = cli.CheckDBConnection(allConfig.Data.Database) + if resp.DBConnectionSuccess { + resp.DbTableExist = cli.CheckDBTableExist(allConfig.Data.Database) + } + handler.HandleResponse(ctx, nil, resp) +} + +// CheckDatabase check database if exist when installation +// @Summary check database if exist when installation +// @Description check database if exist when installation +// @Tags installation +// @Accept json +// @Produce json +// @Param data body install.CheckDatabaseReq true "CheckDatabaseReq" +// @Success 200 {object} handler.RespBody{data=install.CheckConfigFileResp{}} +// @Router /installation/db/check [post] +func CheckDatabase(ctx *gin.Context) { + req := &CheckDatabaseReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp := &CheckDatabaseResp{} + dataConf := &data.Database{ + Driver: req.DbType, + Connection: req.GetConnection(), + } + resp.ConnectionSuccess = cli.CheckDBConnection(dataConf) + if !resp.ConnectionSuccess { + handler.HandleResponse(ctx, errors.BadRequest(reason.DatabaseConnectionFailed), schema.ErrTypeAlert) + return + } + handler.HandleResponse(ctx, nil, resp) +} + +// InitEnvironment init environment +// @Summary init environment +// @Description init environment +// @Tags installation +// @Accept json +// @Produce json +// @Param data body install.CheckDatabaseReq true "CheckDatabaseReq" +// @Success 200 {object} handler.RespBody{data=install.CheckConfigFileResp{}} +// @Router /installation/init [post] +func InitEnvironment(ctx *gin.Context) { + req := &CheckDatabaseReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + // check config file if exist + if cli.CheckConfigFile(confPath) { + log.Debug("config file already exists") + handler.HandleResponse(ctx, nil, nil) + return + } + + if err := cli.InstallConfigFile(confPath); err != nil { + handler.HandleResponse(ctx, errors.BadRequest(reason.InstallConfigFailed), &InitEnvironmentResp{ + Success: false, + CreateConfigFailed: true, + DefaultConfig: string(configs.Config), + ErrType: schema.ErrTypeAlert.ErrType, + }) + return + } + + c, err := conf.ReadConfig(confPath) + if err != nil { + log.Errorf("read config failed %s", err) + handler.HandleResponse(ctx, errors.BadRequest(reason.ReadConfigFailed), nil) + return + } + c.Data.Database.Driver = req.DbType + c.Data.Database.Connection = req.GetConnection() + c.Data.Cache.FilePath = filepath.Join(cli.CacheDir, cli.DefaultCacheFileName) + c.I18n.BundleDir = cli.I18nPath + c.ServiceConfig.UploadPath = cli.UploadFilePath + + if err := conf.RewriteConfig(confPath, c); err != nil { + log.Errorf("rewrite config failed %s", err) + handler.HandleResponse(ctx, errors.BadRequest(reason.ReadConfigFailed), nil) + return + } + handler.HandleResponse(ctx, nil, nil) +} + +// InitBaseInfo init base info +// @Summary init base info +// @Description init base info +// @Tags installation +// @Accept json +// @Produce json +// @Param data body install.InitBaseInfoReq true "InitBaseInfoReq" +// @Success 200 {object} handler.RespBody{data=install.CheckConfigFileResp{}} +// @Router /installation/base-info [post] +func InitBaseInfo(ctx *gin.Context) { + req := &InitBaseInfoReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.FormatSiteUrl() + + c, err := conf.ReadConfig(confPath) + if err != nil { + log.Errorf("read config failed %s", err) + handler.HandleResponse(ctx, errors.BadRequest(reason.ReadConfigFailed), nil) + return + } + + if cli.CheckDBTableExist(c.Data.Database) { + log.Warnf("database is already initialized") + handler.HandleResponse(ctx, nil, nil) + return + } + + if err := migrations.InitDB(c.Data.Database); err != nil { + log.Error("init database error: ", err.Error()) + handler.HandleResponse(ctx, errors.BadRequest(reason.InstallCreateTableFailed), schema.ErrTypeAlert) + return + } + + err = migrations.UpdateInstallInfo(c.Data.Database, req.Language, req.SiteName, req.SiteURL, req.ContactEmail, + req.AdminName, req.AdminPassword, req.AdminEmail) + if err != nil { + log.Error(err) + handler.HandleResponse(ctx, errors.BadRequest(reason.InstallConfigFailed), nil) + return + } + + handler.HandleResponse(ctx, nil, nil) + go func() { + time.Sleep(1 * time.Second) + os.Exit(0) + }() + return +} diff --git a/internal/install/install_main.go b/internal/install/install_main.go new file mode 100644 index 00000000..6586b9d3 --- /dev/null +++ b/internal/install/install_main.go @@ -0,0 +1,32 @@ +package install + +import ( + "fmt" + "os" + + "github.com/answerdev/answer/internal/base/translator" + "github.com/answerdev/answer/internal/cli" +) + +var ( + port = os.Getenv("INSTALL_PORT") + confPath = "" +) + +func Run(configPath string) { + confPath = configPath + // initialize translator for return internationalization error when installing. + _, err := translator.NewTranslator(&translator.I18n{BundleDir: cli.I18nPath}) + if err != nil { + panic(err) + } + + installServer := NewInstallHTTPServer() + if len(port) == 0 { + port = "80" + } + fmt.Printf("[SUCCESS] answer installation service will run at: http://localhost:%s/install/ \n", port) + if err = installServer.Run(":" + port); err != nil { + panic(err) + } +} diff --git a/internal/install/install_req.go b/internal/install/install_req.go new file mode 100644 index 00000000..a80476d3 --- /dev/null +++ b/internal/install/install_req.go @@ -0,0 +1,91 @@ +package install + +import ( + "fmt" + "net/url" + "strings" + + "xorm.io/xorm/schemas" +) + +// CheckConfigFileResp check config file if exist or not response +type CheckConfigFileResp struct { + ConfigFileExist bool `json:"config_file_exist"` + DBConnectionSuccess bool `json:"db_connection_success"` + DbTableExist bool `json:"db_table_exist"` +} + +// CheckDatabaseReq check database +type CheckDatabaseReq struct { + DbType string `validate:"required,oneof=postgres sqlite3 mysql" json:"db_type"` + DbUsername string `json:"db_username"` + DbPassword string `json:"db_password"` + DbHost string `json:"db_host"` + DbName string `json:"db_name"` + DbFile string `json:"db_file"` +} + +// GetConnection get connection string +func (r *CheckDatabaseReq) GetConnection() string { + if r.DbType == string(schemas.SQLITE) { + return r.DbFile + } + if r.DbType == string(schemas.MYSQL) { + return fmt.Sprintf("%s:%s@tcp(%s)/%s", + r.DbUsername, r.DbPassword, r.DbHost, r.DbName) + } + if r.DbType == string(schemas.POSTGRES) { + host, port := parsePgSQLHostPort(r.DbHost) + return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, port, r.DbUsername, r.DbPassword, r.DbName) + } + return "" +} + +func parsePgSQLHostPort(dbHost string) (host string, port string) { + if strings.Contains(dbHost, ":") { + idx := strings.LastIndex(dbHost, ":") + host, port = dbHost[:idx], dbHost[idx+1:] + } else if len(dbHost) > 0 { + host = dbHost + } + if host == "" { + host = "127.0.0.1" + } + if port == "" { + port = "5432" + } + return host, port +} + +// CheckDatabaseResp check database response +type CheckDatabaseResp struct { + ConnectionSuccess bool `json:"connection_success"` +} + +// InitEnvironmentResp init environment response +type InitEnvironmentResp struct { + Success bool `json:"success"` + CreateConfigFailed bool `json:"create_config_failed"` + DefaultConfig string `json:"default_config"` + ErrType string `json:"err_type"` +} + +// InitBaseInfoReq init base info request +type InitBaseInfoReq struct { + Language string `validate:"required,gt=0,lte=30" json:"lang"` + SiteName string `validate:"required,gt=0,lte=30" json:"site_name"` + SiteURL string `validate:"required,gt=0,lte=512,url" json:"site_url"` + ContactEmail string `validate:"required,email,gt=0,lte=500" json:"contact_email"` + AdminName string `validate:"required,gt=4,lte=30" json:"name"` + AdminPassword string `validate:"required,gte=8,lte=32" json:"password"` + AdminEmail string `validate:"required,email,gt=0,lte=500" json:"email"` +} + +func (r *InitBaseInfoReq) FormatSiteUrl() { + parsedUrl, err := url.Parse(r.SiteURL) + if err != nil { + return + } + r.SiteURL = fmt.Sprintf("%s://%s", parsedUrl.Scheme, parsedUrl.Host) +} diff --git a/internal/install/install_server.go b/internal/install/install_server.go new file mode 100644 index 00000000..c0c38a7f --- /dev/null +++ b/internal/install/install_server.go @@ -0,0 +1,64 @@ +package install + +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) +} + +// NewInstallHTTPServer new install http server. +func NewInstallHTTPServer() *gin.Engine { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.GET("/healthz", func(ctx *gin.Context) { ctx.String(200, "OK") }) + r.StaticFS("/static", http.FS(&_resource{ + fs: ui.Build, + })) + + installApi := r.Group("") + installApi.GET("/install", WebPage) + installApi.GET("/50x", WebPage) + installApi.GET("/installation/language/options", LangOptions) + installApi.POST("/installation/db/check", CheckDatabase) + installApi.POST("/installation/config-file/check", CheckConfigFile) + installApi.POST("/installation/init", InitEnvironment) + installApi.POST("/installation/base-info", InitBaseInfo) + + r.NoRoute(func(ctx *gin.Context) { + ctx.Redirect(http.StatusFound, "/50x") + }) + return r +} + +func WebPage(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)) +} diff --git a/internal/migrations/init.go b/internal/migrations/init.go index e9865b13..cebdc1c7 100644 --- a/internal/migrations/init.go +++ b/internal/migrations/init.go @@ -1,10 +1,12 @@ package migrations import ( + "encoding/json" "fmt" "github.com/answerdev/answer/internal/base/data" "github.com/answerdev/answer/internal/entity" + "golang.org/x/crypto/bcrypt" "xorm.io/xorm" ) @@ -55,11 +57,6 @@ func InitDB(dataConf *data.Database) (err error) { return fmt.Errorf("init admin user failed: %s", err) } - err = initSiteInfo(engine) - if err != nil { - return fmt.Errorf("init site info failed: %s", err) - } - err = initConfigTable(engine) if err != nil { return fmt.Errorf("init config table: %s", err) @@ -82,12 +79,79 @@ func initAdminUser(engine *xorm.Engine) error { return err } -func initSiteInfo(engine *xorm.Engine) error { +func initSiteInfo(engine *xorm.Engine, language, siteName, siteURL, contactEmail string) error { + interfaceData := map[string]string{ + "logo": "", + "theme": "black", + "language": language, + } + interfaceDataBytes, _ := json.Marshal(interfaceData) _, err := engine.InsertOne(&entity.SiteInfo{ Type: "interface", - Content: `{"logo":"","theme":"black","language":"en_US"}`, + Content: string(interfaceDataBytes), Status: 1, }) + if err != nil { + return err + } + + generalData := map[string]string{ + "name": siteName, + "site_url": siteURL, + "contact_email": contactEmail, + } + generalDataBytes, _ := json.Marshal(generalData) + _, err = engine.InsertOne(&entity.SiteInfo{ + Type: "general", + Content: string(generalDataBytes), + Status: 1, + }) + return err +} + +func updateAdminInfo(engine *xorm.Engine, adminName, adminPassword, adminEmail string) error { + generateFromPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("") + } + adminPassword = string(generateFromPassword) + + // update admin info + _, err = engine.ID("1").Update(&entity.User{ + Username: adminName, + Pass: adminPassword, + EMail: adminEmail, + DisplayName: adminName, + }) + if err != nil { + return fmt.Errorf("update admin user info failed: %s", err) + } + return nil +} + +// UpdateInstallInfo update some init data about the admin interface and admin password +func UpdateInstallInfo(dataConf *data.Database, language string, + siteName string, + siteURL string, + contactEmail string, + adminName string, + adminPassword string, + adminEmail string) error { + + engine, err := data.NewDB(false, dataConf) + if err != nil { + return fmt.Errorf("database connection error: %s", err) + } + + err = updateAdminInfo(engine, adminName, adminPassword, adminEmail) + if err != nil { + return fmt.Errorf("update admin info failed: %s", err) + } + + err = initSiteInfo(engine, language, siteName, siteURL, contactEmail) + if err != nil { + return fmt.Errorf("init site info failed: %s", err) + } return err } @@ -125,7 +189,7 @@ func initConfigTable(engine *xorm.Engine) error { {ID: 30, Key: "answer.vote_up", Value: `0`}, {ID: 31, Key: "answer.vote_up_cancel", Value: `0`}, {ID: 32, Key: "question.follow", Value: `0`}, - {ID: 33, Key: "email.config", Value: `{"from_name":"answer","from_email":"answer@answer.com","smtp_host":"smtp.answer.org","smtp_port":465,"smtp_password":"answer","smtp_username":"answer@answer.com","smtp_authentication":true,"encryption":"","register_title":"[{{.SiteName}}] Confirm your new account","register_body":"Welcome to {{.SiteName}}

\n\nClick the following link to confirm and activate your new account:
\n{{.RegisterUrl}}

\n\nIf the above link is not clickable, try copying and pasting it into the address bar of your web browser.\n","pass_reset_title":"[{{.SiteName }}] Password reset","pass_reset_body":"Somebody asked to reset your password on [{{.SiteName}}].

\n\nIf it was not you, you can safely ignore this email.

\n\nClick the following link to choose a new password:
\n{{.PassResetUrl}}\n","change_title":"[{{.SiteName}}] Confirm your new email address","change_body":"Confirm your new email address for {{.SiteName}} by clicking on the following link:

\n\n{{.ChangeEmailUrl}}

\n\nIf you did not request this change, please ignore this email.\n","test_title":"[{{.SiteName}}] Test Email","test_body":"This is a test email."}`}, + {ID: 33, Key: "email.config", Value: `{"from_name":"","from_email":"","smtp_host":"","smtp_port":465,"smtp_password":"","smtp_username":"","smtp_authentication":true,"encryption":"","register_title":"[{{.SiteName}}] Confirm your new account","register_body":"Welcome to {{.SiteName}}

\n\nClick the following link to confirm and activate your new account:
\n{{.RegisterUrl}}

\n\nIf the above link is not clickable, try copying and pasting it into the address bar of your web browser.\n","pass_reset_title":"[{{.SiteName }}] Password reset","pass_reset_body":"Somebody asked to reset your password on [{{.SiteName}}].

\n\nIf it was not you, you can safely ignore this email.

\n\nClick the following link to choose a new password:
\n{{.PassResetUrl}}\n","change_title":"[{{.SiteName}}] Confirm your new email address","change_body":"Confirm your new email address for {{.SiteName}} by clicking on the following link:

\n\n{{.ChangeEmailUrl}}

\n\nIf you did not request this change, please ignore this email.\n","test_title":"[{{.SiteName}}] Test Email","test_body":"This is a test email."}`}, {ID: 35, Key: "tag.follow", Value: `0`}, {ID: 36, Key: "rank.question.add", Value: `0`}, {ID: 37, Key: "rank.question.edit", Value: `0`}, diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index f4f4efd8..200e4813 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -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++ diff --git a/internal/migrations/v1.go b/internal/migrations/v1.go new file mode 100644 index 00000000..b9e316d9 --- /dev/null +++ b/internal/migrations/v1.go @@ -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)) +} diff --git a/internal/repo/activity_common/vote.go b/internal/repo/activity_common/vote.go index abbea28f..16f73fc4 100644 --- a/internal/repo/activity_common/vote.go +++ b/internal/repo/activity_common/vote.go @@ -4,8 +4,10 @@ import ( "context" "github.com/answerdev/answer/internal/base/data" + "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/service/activity_common" + "github.com/segmentfault/pacman/errors" ) // VoteRepo activity repository @@ -39,3 +41,12 @@ func (vr *VoteRepo) GetVoteStatus(ctx context.Context, objectID, userID string) } return "" } + +func (vr *VoteRepo) GetVoteCount(ctx context.Context, activityTypes []int) (count int64, err error) { + list := make([]*entity.Activity, 0) + count, err = vr.data.DB.Where("cancelled =0").In("activity_type", activityTypes).FindAndCount(&list) + if err != nil { + return count, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/repo/answer/answer_repo.go b/internal/repo/answer/answer_repo.go index 0f6de0a7..a21c2aa9 100644 --- a/internal/repo/answer/answer_repo.go +++ b/internal/repo/answer/answer_repo.go @@ -5,6 +5,7 @@ import ( "strings" "time" "unicode" + "xorm.io/builder" "github.com/answerdev/answer/internal/base/constant" @@ -102,6 +103,16 @@ func (ar *answerRepo) GetAnswer(ctx context.Context, id string) ( return } +// GetQuestionCount +func (ar *answerRepo) GetAnswerCount(ctx context.Context) (count int64, err error) { + list := make([]*entity.Answer, 0) + count, err = ar.data.DB.Where("status = ?", entity.AnswerStatusAvailable).FindAndCount(&list) + if err != nil { + return count, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + // GetAnswerList get answer list all func (ar *answerRepo) GetAnswerList(ctx context.Context, answer *entity.Answer) (answerList []*entity.Answer, err error) { answerList = make([]*entity.Answer, 0) diff --git a/internal/repo/comment/comment_repo.go b/internal/repo/comment/comment_repo.go index a1a7ab90..992e6a2f 100644 --- a/internal/repo/comment/comment_repo.go +++ b/internal/repo/comment/comment_repo.go @@ -79,6 +79,15 @@ func (cr *commentRepo) GetComment(ctx context.Context, commentID string) ( return } +func (cr *commentRepo) GetCommentCount(ctx context.Context) (count int64, err error) { + list := make([]*entity.Comment, 0) + count, err = cr.data.DB.Where("status = ?", entity.CommentStatusAvailable).FindAndCount(&list) + if err != nil { + return count, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + // GetCommentPage get comment page func (cr *commentRepo) GetCommentPage(ctx context.Context, commentQuery *comment.CommentQuery) ( commentList []*entity.Comment, total int64, err error, diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 2e26006f..4d90a09e 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -5,6 +5,7 @@ import ( "strings" "time" "unicode" + "xorm.io/builder" "github.com/answerdev/answer/internal/base/constant" @@ -162,6 +163,16 @@ func (qr *questionRepo) GetQuestionList(ctx context.Context, question *entity.Qu return } +func (qr *questionRepo) GetQuestionCount(ctx context.Context) (count int64, err error) { + questionList := make([]*entity.Question, 0) + + count, err = qr.data.DB.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusclosed}).FindAndCount(&questionList) + if err != nil { + return count, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + // GetQuestionPage get question page func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, question *entity.Question) (questionList []*entity.Question, total int64, err error) { questionList = make([]*entity.Question, 0) diff --git a/internal/repo/report/report_repo.go b/internal/repo/report/report_repo.go index 1ebf820e..08747246 100644 --- a/internal/repo/report/report_repo.go +++ b/internal/repo/report/report_repo.go @@ -94,3 +94,12 @@ func (ar *reportRepo) UpdateByID( } return } + +func (vr *reportRepo) GetReportCount(ctx context.Context) (count int64, err error) { + list := make([]*entity.Report, 0) + count, err = vr.data.DB.Where("status =?", entity.ReportStatusPending).FindAndCount(&list) + if err != nil { + return count, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/repo/search_common/search_repo.go b/internal/repo/search_common/search_repo.go index b2db83c3..b973df8a 100644 --- a/internal/repo/search_common/search_repo.go +++ b/internal/repo/search_common/search_repo.go @@ -3,6 +3,7 @@ package search_common import ( "context" "fmt" + "github.com/answerdev/answer/pkg/htmltext" "strings" "time" @@ -25,7 +26,7 @@ var ( "`question`.`id`", "`question`.`id` as `question_id`", "`title`", - "`original_text`", + "`parsed_text`", "`question`.`created_at`", "`user_id`", "`vote_count`", @@ -38,7 +39,7 @@ var ( "`answer`.`id` as `id`", "`question_id`", "`question`.`title` as `title`", - "`answer`.`original_text` as `original_text`", + "`answer`.`parsed_text` as `parsed_text`", "`answer`.`created_at`", "`answer`.`user_id` as `user_id`", "`answer`.`vote_count` as `vote_count`", @@ -142,13 +143,22 @@ func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagID, argsA = append(argsA, votes) } - b = b.Union("all", ub) - - querySQL, _, err := builder.MySQL().Select("*").From(b, "t").OrderBy(sr.parseOrder(ctx, order)).Limit(size, page-1).ToSQL() + //b = b.Union("all", ub) + ubSQL, _, err := ub.ToSQL() if err != nil { return } - countSQL, _, err := builder.MySQL().Select("count(*) total").From(b, "c").ToSQL() + bSQL, _, err := b.ToSQL() + if err != nil { + return + } + sql := fmt.Sprintf("(%s UNION ALL %s)", ubSQL, bSQL) + + querySQL, _, err := builder.MySQL().Select("*").From(sql, "t").OrderBy(sr.parseOrder(ctx, order)).Limit(size, page-1).ToSQL() + if err != nil { + return + } + countSQL, _, err := builder.MySQL().Select("count(*) total").From(sql, "c").ToSQL() if err != nil { return } @@ -412,7 +422,7 @@ func (sr *searchRepo) parseResult(ctx context.Context, res []map[string][]byte) object = schema.SearchObject{ ID: string(r["id"]), Title: string(r["title"]), - Excerpt: cutOutParsedText(string(r["original_text"])), + Excerpt: htmltext.FetchExcerpt(string(r["parsed_text"]), "...", 240), CreatedAtParsed: tp.Unix(), UserInfo: userInfo, Tags: tags, @@ -443,15 +453,6 @@ func (sr *searchRepo) userBasicInfoFormat(ctx context.Context, dbinfo *entity.Us } } -func cutOutParsedText(parsedText string) string { - parsedText = strings.TrimSpace(parsedText) - idx := strings.Index(parsedText, "\n") - if idx >= 0 { - parsedText = parsedText[0:idx] - } - return parsedText -} - func addRelevanceField(searchFields, words, fields []string) (res []string, args []interface{}) { relevanceRes := []string{} args = []interface{}{} diff --git a/internal/repo/user/user_repo.go b/internal/repo/user/user_repo.go index db1679ed..fb93c53c 100644 --- a/internal/repo/user/user_repo.go +++ b/internal/repo/user/user_repo.go @@ -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). @@ -149,3 +157,12 @@ func (ur *userRepo) GetByEmail(ctx context.Context, email string) (userInfo *ent } return } + +func (vr *userRepo) GetUserCount(ctx context.Context) (count int64, err error) { + list := make([]*entity.User, 0) + count, err = vr.data.DB.Where("mail_status =?", entity.EmailStatusAvailable).And("status =?", entity.UserStatusAvailable).FindAndCount(&list) + if err != nil { + return count, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 56a972a8..fc7fab95 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -27,6 +27,7 @@ type AnswerAPIRouter struct { siteInfoController *controller_backyard.SiteInfoController siteinfoController *controller.SiteinfoController notificationController *controller.NotificationController + dashboardController *controller.DashboardController } func NewAnswerAPIRouter( @@ -50,6 +51,7 @@ func NewAnswerAPIRouter( siteInfoController *controller_backyard.SiteInfoController, siteinfoController *controller.SiteinfoController, notificationController *controller.NotificationController, + dashboardController *controller.DashboardController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ @@ -73,13 +75,14 @@ func NewAnswerAPIRouter( siteInfoController: siteInfoController, notificationController: notificationController, siteinfoController: siteinfoController, + dashboardController: dashboardController, } } 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) @@ -88,7 +91,6 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { // user r.GET("/user/info", a.userController.GetUserInfoByUserID) - r.GET("/user/status", a.userController.GetUserStatus) r.GET("/user/action/record", a.userController.ActionRecord) r.POST("/user/login/email", a.userController.UserEmailLogin) r.POST("/user/register/email", a.userController.UserRegisterByEmail) @@ -131,7 +133,7 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/personal/rank/page", a.rankController.GetRankPersonalWithPage) //siteinfo - r.GET("/siteinfo", a.siteinfoController.GetInfo) + r.GET("/siteinfo", a.siteinfoController.GetSiteInfo) } func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { @@ -177,6 +179,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) @@ -213,7 +216,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) @@ -225,4 +228,7 @@ func (a *AnswerAPIRouter) RegisterAnswerCmsAPIRouter(r *gin.RouterGroup) { r.PUT("/siteinfo/interface", a.siteInfoController.UpdateInterface) r.GET("/setting/smtp", a.siteInfoController.GetSMTPConfig) r.PUT("/setting/smtp", a.siteInfoController.UpdateSMTPConfig) + + //dashboard + r.GET("/dashboard", a.dashboardController.DashboardInfo) } diff --git a/internal/router/config.go b/internal/router/config.go index 742c9c8c..2f167185 100644 --- a/internal/router/config.go +++ b/internal/router/config.go @@ -2,8 +2,8 @@ package router // SwaggerConfig struct describes configure for the Swagger API endpoint type SwaggerConfig struct { - Show bool `json:"show"` - Protocol string `json:"protocol"` - Host string `json:"host"` - Address string `json:"address"` + Show bool `json:"show" mapstructure:"show" yaml:"show"` + Protocol string `json:"protocol" mapstructure:"protocol" yaml:"protocol"` + Host string `json:"host" mapstructure:"host" yaml:"host"` + Address string `json:"address" mapstructure:"address" yaml:"address"` } diff --git a/internal/router/ui.go b/internal/router/ui.go index 3498265c..f1296d9f 100644 --- a/internal/router/ui.go +++ b/internal/router/ui.go @@ -78,6 +78,9 @@ func (a *UIRouter) Register(r *gin.Engine) { filePath = UIRootFilePath + name case "/manifest.json": filePath = UIRootFilePath + name + case "/install": + c.Redirect(http.StatusFound, "/") + return default: filePath = UIIndexFilePath c.Header("content-type", "text/html;charset=utf-8") diff --git a/internal/schema/dashboard_schema.go b/internal/schema/dashboard_schema.go new file mode 100644 index 00000000..1ed09010 --- /dev/null +++ b/internal/schema/dashboard_schema.go @@ -0,0 +1,38 @@ +package schema + +import "time" + +var AppStartTime time.Time + +const ( + DashBoardCachekey = "answer@dashboard" + DashBoardCacheTime = 60 * time.Minute +) + +type DashboardInfo struct { + QuestionCount int64 `json:"question_count"` + AnswerCount int64 `json:"answer_count"` + CommentCount int64 `json:"comment_count"` + VoteCount int64 `json:"vote_count"` + UserCount int64 `json:"user_count"` + ReportCount int64 `json:"report_count"` + UploadingFiles bool `json:"uploading_files"` + SMTP bool `json:"smtp"` + HTTPS bool `json:"https"` + TimeZone string `json:"time_zone"` + OccupyingStorageSpace string `json:"occupying_storage_space"` + AppStartTime string `json:"app_start_time"` + VersionInfo DashboardInfoVersion `json:"version_info"` +} + +type DashboardInfoVersion struct { + Version string `json:"version"` + RemoteVersion string `json:"remote_version"` +} + +type RemoteVersion struct { + Release struct { + Version string `json:"version"` + URL string `json:"url"` + } `json:"release"` +} diff --git a/internal/schema/err_schema.go b/internal/schema/err_schema.go index 3270b365..8acb48ca 100644 --- a/internal/schema/err_schema.go +++ b/internal/schema/err_schema.go @@ -7,3 +7,5 @@ type ErrTypeData struct { var ErrTypeModal = ErrTypeData{ErrType: "modal"} var ErrTypeToast = ErrTypeData{ErrType: "toast"} + +var ErrTypeAlert = ErrTypeData{ErrType: "alert"} diff --git a/internal/schema/lang_schema.go b/internal/schema/lang_schema.go deleted file mode 100644 index 98abe874..00000000 --- a/internal/schema/lang_schema.go +++ /dev/null @@ -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", - }, -} diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index ff93e72d..85a643ed 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -1,17 +1,33 @@ package schema +import ( + "fmt" + "net/url" +) + // SiteGeneralReq site general request type SiteGeneralReq struct { - Name string `validate:"required,gt=1,lte=128" comment:"site name" form:"name" json:"name"` - ShortDescription string `validate:"required,gt=3,lte=255" comment:"short site description" form:"short_description" json:"short_description"` - Description string `validate:"required,gt=3,lte=2000" comment:"site description" form:"description" json:"description"` + Name string `validate:"required,gt=1,lte=128" form:"name" json:"name"` + ShortDescription string `validate:"required,gt=3,lte=255" form:"short_description" json:"short_description"` + Description string `validate:"required,gt=3,lte=2000" form:"description" json:"description"` + SiteUrl string `validate:"required,gt=1,lte=512,url" form:"site_url" json:"site_url"` + ContactEmail string `validate:"required,gt=1,lte=512,email" form:"contact_email" json:"contact_email"` +} + +func (r *SiteGeneralReq) FormatSiteUrl() { + parsedUrl, err := url.Parse(r.SiteUrl) + if err != nil { + return + } + r.SiteUrl = fmt.Sprintf("%s://%s", parsedUrl.Scheme, parsedUrl.Host) } // SiteInterfaceReq site interface request type SiteInterfaceReq struct { - Logo string `validate:"omitempty,gt=0,lte=256" comment:"logo" form:"logo" json:"logo"` - Theme string `validate:"required,gt=1,lte=128" comment:"theme" form:"theme" json:"theme"` - Language string `validate:"required,gt=1,lte=128" comment:"interface language" form:"language" json:"language"` + Logo string `validate:"omitempty,gt=0,lte=256" form:"logo" json:"logo"` + Theme string `validate:"required,gt=1,lte=128" form:"theme" json:"theme"` + Language string `validate:"required,gt=1,lte=128" form:"language" json:"language"` + TimeZone string `validate:"required,gt=1,lte=128" form:"time_zone" json:"time_zone"` } // SiteGeneralResp site general response diff --git a/internal/schema/user_schema.go b/internal/schema/user_schema.go index d871d5ea..d9455101 100644 --- a/internal/schema/user_schema.go +++ b/internal/schema/user_schema.go @@ -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 diff --git a/internal/service/activity_common/vote.go b/internal/service/activity_common/vote.go index fb97885b..16de6967 100644 --- a/internal/service/activity_common/vote.go +++ b/internal/service/activity_common/vote.go @@ -7,4 +7,5 @@ import ( // VoteRepo activity repository type VoteRepo interface { GetVoteStatus(ctx context.Context, objectId, userId string) (status string) + GetVoteCount(ctx context.Context, activityTypes []int) (count int64, err error) } diff --git a/internal/service/answer_common/answer.go b/internal/service/answer_common/answer.go index c84a1d15..196d6628 100644 --- a/internal/service/answer_common/answer.go +++ b/internal/service/answer_common/answer.go @@ -5,6 +5,7 @@ import ( "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/pkg/htmltext" ) type AnswerRepo interface { @@ -20,6 +21,7 @@ type AnswerRepo interface { SearchList(ctx context.Context, search *entity.AnswerSearch) ([]*entity.Answer, int64, error) CmsSearchList(ctx context.Context, search *entity.CmsAnswerSearch) ([]*entity.Answer, int64, error) UpdateAnswerStatus(ctx context.Context, answer *entity.Answer) (err error) + GetAnswerCount(ctx context.Context) (count int64, err error) } // AnswerCommon user service @@ -74,11 +76,11 @@ func (as *AnswerCommon) AdminShowFormat(ctx context.Context, data *entity.Answer info := schema.AdminAnswerInfo{} info.ID = data.ID info.QuestionID = data.QuestionID - info.Description = data.ParsedText info.Adopted = data.Adopted info.VoteCount = data.VoteCount info.CreateTime = data.CreatedAt.Unix() info.UpdateTime = data.UpdatedAt.Unix() info.UserID = data.UserID + info.Description = htmltext.FetchExcerpt(data.ParsedText, "...", 240) return &info } diff --git a/internal/service/comment_common/comment_service.go b/internal/service/comment_common/comment_service.go index dc1f9fc9..19c2b90a 100644 --- a/internal/service/comment_common/comment_service.go +++ b/internal/service/comment_common/comment_service.go @@ -12,6 +12,7 @@ import ( // CommentCommonRepo comment repository type CommentCommonRepo interface { GetComment(ctx context.Context, commentID string) (comment *entity.Comment, exist bool, err error) + GetCommentCount(ctx context.Context) (count int64, err error) } // CommentCommonService user service diff --git a/internal/service/dashboard/dashboard_service.go b/internal/service/dashboard/dashboard_service.go new file mode 100644 index 00000000..af462823 --- /dev/null +++ b/internal/service/dashboard/dashboard_service.go @@ -0,0 +1,237 @@ +package dashboard + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "time" + + "github.com/answerdev/answer/internal/base/constant" + "github.com/answerdev/answer/internal/base/data" + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/internal/schema" + "github.com/answerdev/answer/internal/service/activity_common" + answercommon "github.com/answerdev/answer/internal/service/answer_common" + "github.com/answerdev/answer/internal/service/comment_common" + "github.com/answerdev/answer/internal/service/config" + "github.com/answerdev/answer/internal/service/export" + questioncommon "github.com/answerdev/answer/internal/service/question_common" + "github.com/answerdev/answer/internal/service/report_common" + "github.com/answerdev/answer/internal/service/service_config" + "github.com/answerdev/answer/internal/service/siteinfo_common" + usercommon "github.com/answerdev/answer/internal/service/user_common" + "github.com/answerdev/answer/pkg/dir" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +type DashboardService struct { + questionRepo questioncommon.QuestionRepo + answerRepo answercommon.AnswerRepo + commentRepo comment_common.CommentCommonRepo + voteRepo activity_common.VoteRepo + userRepo usercommon.UserRepo + reportRepo report_common.ReportRepo + configRepo config.ConfigRepo + siteInfoService *siteinfo_common.SiteInfoCommonService + serviceConfig *service_config.ServiceConfig + + data *data.Data +} + +func NewDashboardService( + questionRepo questioncommon.QuestionRepo, + answerRepo answercommon.AnswerRepo, + commentRepo comment_common.CommentCommonRepo, + voteRepo activity_common.VoteRepo, + userRepo usercommon.UserRepo, + reportRepo report_common.ReportRepo, + configRepo config.ConfigRepo, + siteInfoService *siteinfo_common.SiteInfoCommonService, + serviceConfig *service_config.ServiceConfig, + + data *data.Data, +) *DashboardService { + return &DashboardService{ + questionRepo: questionRepo, + answerRepo: answerRepo, + commentRepo: commentRepo, + voteRepo: voteRepo, + userRepo: userRepo, + reportRepo: reportRepo, + configRepo: configRepo, + siteInfoService: siteInfoService, + serviceConfig: serviceConfig, + + data: data, + } +} + +func (ds *DashboardService) StatisticalByCache(ctx context.Context) (*schema.DashboardInfo, error) { + dashboardInfo := &schema.DashboardInfo{} + infoStr, err := ds.data.Cache.GetString(ctx, schema.DashBoardCachekey) + if err != nil { + info, statisticalErr := ds.Statistical(ctx) + if statisticalErr != nil { + return dashboardInfo, err + } + setCacheErr := ds.SetCache(ctx, info) + if setCacheErr != nil { + log.Error("ds.SetCache", setCacheErr) + } + return info, err + } + err = json.Unmarshal([]byte(infoStr), dashboardInfo) + if err != nil { + return dashboardInfo, err + } + startTime := time.Now().Unix() - schema.AppStartTime.Unix() + dashboardInfo.AppStartTime = fmt.Sprintf("%d", startTime) + return dashboardInfo, nil +} + +func (ds *DashboardService) SetCache(ctx context.Context, info *schema.DashboardInfo) error { + infoStr, err := json.Marshal(info) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + err = ds.data.Cache.SetString(ctx, schema.DashBoardCachekey, string(infoStr), schema.DashBoardCacheTime) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + return nil +} + +// Statistical +func (ds *DashboardService) Statistical(ctx context.Context) (*schema.DashboardInfo, error) { + dashboardInfo := &schema.DashboardInfo{} + questionCount, err := ds.questionRepo.GetQuestionCount(ctx) + if err != nil { + return dashboardInfo, err + } + answerCount, err := ds.answerRepo.GetAnswerCount(ctx) + if err != nil { + return dashboardInfo, err + } + commentCount, err := ds.commentRepo.GetCommentCount(ctx) + if err != nil { + return dashboardInfo, err + } + + typeKeys := []string{ + "question.vote_up", + "question.vote_down", + "answer.vote_up", + "answer.vote_down", + } + var activityTypes []int + + for _, typeKey := range typeKeys { + var t int + t, err = ds.configRepo.GetConfigType(typeKey) + if err != nil { + continue + } + activityTypes = append(activityTypes, t) + } + + voteCount, err := ds.voteRepo.GetVoteCount(ctx, activityTypes) + if err != nil { + return dashboardInfo, err + } + userCount, err := ds.userRepo.GetUserCount(ctx) + if err != nil { + return dashboardInfo, err + } + + reportCount, err := ds.reportRepo.GetReportCount(ctx) + if err != nil { + return dashboardInfo, err + } + + siteInfoInterface, err := ds.siteInfoService.GetSiteInterface(ctx) + if err != nil { + return dashboardInfo, err + } + + dashboardInfo.QuestionCount = questionCount + dashboardInfo.AnswerCount = answerCount + dashboardInfo.CommentCount = commentCount + dashboardInfo.VoteCount = voteCount + dashboardInfo.UserCount = userCount + dashboardInfo.ReportCount = reportCount + + dashboardInfo.UploadingFiles = true + emailconfig, err := ds.GetEmailConfig() + if err != nil { + return dashboardInfo, err + } + if emailconfig.SMTPHost != "" { + dashboardInfo.SMTP = true + } + siteGeneral, err := ds.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + return dashboardInfo, err + } + siteUrl, err := url.Parse(siteGeneral.SiteUrl) + if err != nil { + return dashboardInfo, err + } + if siteUrl.Scheme == "https" { + dashboardInfo.HTTPS = true + } + + dirSize, err := dir.DirSize(ds.serviceConfig.UploadPath) + if err != nil { + return dashboardInfo, err + } + size := dir.FormatFileSize(dirSize) + dashboardInfo.OccupyingStorageSpace = size + startTime := time.Now().Unix() - schema.AppStartTime.Unix() + dashboardInfo.AppStartTime = fmt.Sprintf("%d", startTime) + dashboardInfo.TimeZone = siteInfoInterface.TimeZone + dashboardInfo.VersionInfo.Version = constant.Version + dashboardInfo.VersionInfo.RemoteVersion = ds.RemoteVersion(ctx) + return dashboardInfo, nil +} + +func (ds *DashboardService) RemoteVersion(ctx context.Context) string { + url := "https://answer.dev/getlatest" + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "Answer/"+constant.Version) + resp, err := (&http.Client{}).Do(req) + if err != nil { + log.Error("http.Client error", err) + return "" + } + defer resp.Body.Close() + + respByte, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Error("http.Client error", err) + return "" + } + remoteVersion := &schema.RemoteVersion{} + err = json.Unmarshal(respByte, remoteVersion) + if err != nil { + log.Error("json.Unmarshal error", err) + return "" + } + return remoteVersion.Release.Version +} + +func (ds *DashboardService) GetEmailConfig() (ec *export.EmailConfig, err error) { + emailConf, err := ds.configRepo.GetString("email.config") + if err != nil { + return nil, err + } + ec = &export.EmailConfig{} + err = json.Unmarshal([]byte(emailConf), ec) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return ec, nil +} diff --git a/internal/service/dashboard/dashboard_test.go b/internal/service/dashboard/dashboard_test.go new file mode 100644 index 00000000..cfdd5f81 --- /dev/null +++ b/internal/service/dashboard/dashboard_test.go @@ -0,0 +1 @@ +package dashboard diff --git a/internal/service/provider.go b/internal/service/provider.go index 3901766e..5c0e4ab9 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -8,6 +8,7 @@ import ( collectioncommon "github.com/answerdev/answer/internal/service/collection_common" "github.com/answerdev/answer/internal/service/comment" "github.com/answerdev/answer/internal/service/comment_common" + "github.com/answerdev/answer/internal/service/dashboard" "github.com/answerdev/answer/internal/service/export" "github.com/answerdev/answer/internal/service/follow" "github.com/answerdev/answer/internal/service/meta" @@ -21,6 +22,8 @@ import ( "github.com/answerdev/answer/internal/service/report_backyard" "github.com/answerdev/answer/internal/service/report_handle_backyard" "github.com/answerdev/answer/internal/service/revision_common" + "github.com/answerdev/answer/internal/service/siteinfo" + "github.com/answerdev/answer/internal/service/siteinfo_common" "github.com/answerdev/answer/internal/service/tag" tagcommon "github.com/answerdev/answer/internal/service/tag_common" "github.com/answerdev/answer/internal/service/uploader" @@ -61,8 +64,10 @@ var ProviderSetService = wire.NewSet( report_backyard.NewReportBackyardService, user_backyard.NewUserBackyardService, reason.NewReasonService, - NewSiteInfoService, + siteinfo_common.NewSiteInfoCommonService, + siteinfo.NewSiteInfoService, notficationcommon.NewNotificationCommon, notification.NewNotificationService, activity.NewAnswerActivityService, + dashboard.NewDashboardService, ) diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 3cb944ff..e8b97e18 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -38,6 +38,7 @@ type QuestionRepo interface { UpdateLastAnswer(ctx context.Context, question *entity.Question) (err error) FindByID(ctx context.Context, id []string) (questionList []*entity.Question, err error) CmsSearchList(ctx context.Context, search *schema.CmsQuestionSearch) ([]*entity.Question, int64, error) + GetQuestionCount(ctx context.Context) (count int64, err error) } // QuestionCommon user service diff --git a/internal/service/report_backyard/report_backyard.go b/internal/service/report_backyard/report_backyard.go index 947e7197..69b87c85 100644 --- a/internal/service/report_backyard/report_backyard.go +++ b/internal/service/report_backyard/report_backyard.go @@ -2,9 +2,8 @@ package report_backyard import ( "context" - "strings" - "github.com/answerdev/answer/internal/service/config" + "github.com/answerdev/answer/pkg/htmltext" "github.com/answerdev/answer/internal/base/pager" "github.com/answerdev/answer/internal/base/reason" @@ -180,20 +179,20 @@ func (rs *ReportBackyardService) parseObject(ctx context.Context, resp *[]*schem case "question": r.QuestionID = questionId r.Title = question.Title - r.Excerpt = rs.cutOutTagParsedText(question.OriginalText) + r.Excerpt = htmltext.FetchExcerpt(question.ParsedText, "...", 240) case "answer": r.QuestionID = questionId r.AnswerID = answerId r.Title = question.Title - r.Excerpt = rs.cutOutTagParsedText(answer.OriginalText) + r.Excerpt = htmltext.FetchExcerpt(answer.ParsedText, "...", 240) case "comment": r.QuestionID = questionId r.AnswerID = answerId r.CommentID = commentId r.Title = question.Title - r.Excerpt = rs.cutOutTagParsedText(cmt.OriginalText) + r.Excerpt = htmltext.FetchExcerpt(cmt.ParsedText, "...", 240) } // parse reason @@ -214,12 +213,3 @@ func (rs *ReportBackyardService) parseObject(ctx context.Context, resp *[]*schem } resp = &res } - -func (rs *ReportBackyardService) cutOutTagParsedText(parsedText string) string { - parsedText = strings.TrimSpace(parsedText) - idx := strings.Index(parsedText, "\n") - if idx >= 0 { - parsedText = parsedText[0:idx] - } - return parsedText -} diff --git a/internal/service/report_common/report_common.go b/internal/service/report_common/report_common.go index 5b5f3827..1b8c59cc 100644 --- a/internal/service/report_common/report_common.go +++ b/internal/service/report_common/report_common.go @@ -2,6 +2,7 @@ package report_common import ( "context" + "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/schema" ) @@ -12,4 +13,5 @@ type ReportRepo interface { GetReportListPage(ctx context.Context, query schema.GetReportListPageDTO) (reports []entity.Report, total int64, err error) GetByID(ctx context.Context, id string) (report entity.Report, exist bool, err error) UpdateByID(ctx context.Context, id string, handleData entity.Report) (err error) + GetReportCount(ctx context.Context) (count int64, err error) } diff --git a/internal/service/service_config/service_config.go b/internal/service/service_config/service_config.go index 33e1f626..b5c1f56b 100644 --- a/internal/service/service_config/service_config.go +++ b/internal/service/service_config/service_config.go @@ -1,7 +1,6 @@ package service_config type ServiceConfig struct { - SecretKey string `json:"secret_key" mapstructure:"secret_key"` - WebHost string `json:"web_host" mapstructure:"web_host"` - UploadPath string `json:"upload_path" mapstructure:"upload_path"` + SecretKey string `json:"secret_key" mapstructure:"secret_key" yaml:"secret_key"` + UploadPath string `json:"upload_path" mapstructure:"upload_path" yaml:"upload_path"` } diff --git a/internal/service/siteinfo_service.go b/internal/service/siteinfo/siteinfo_service.go similarity index 72% rename from internal/service/siteinfo_service.go rename to internal/service/siteinfo/siteinfo_service.go index a4f23fd3..66c49479 100644 --- a/internal/service/siteinfo_service.go +++ b/internal/service/siteinfo/siteinfo_service.go @@ -1,10 +1,12 @@ -package service +package siteinfo import ( "context" "encoding/json" + "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" @@ -25,41 +27,37 @@ func NewSiteInfoService(siteInfoRepo siteinfo_common.SiteInfoRepo, emailService } } -func (s *SiteInfoService) GetSiteGeneral(ctx context.Context) (resp schema.SiteGeneralResp, err error) { - var ( - siteType = "general" - siteInfo *entity.SiteInfo - exist bool - ) - resp = schema.SiteGeneralResp{} - - siteInfo, exist, err = s.siteInfoRepo.GetByType(ctx, siteType) +// GetSiteGeneral get site info general +func (s *SiteInfoService) GetSiteGeneral(ctx context.Context) (resp *schema.SiteGeneralResp, err error) { + siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeGeneral) + if err != nil { + return nil, err + } if !exist { - return + return nil, errors.BadRequest(reason.SiteInfoNotFound) } - _ = json.Unmarshal([]byte(siteInfo.Content), &resp) - return + resp = &schema.SiteGeneralResp{} + _ = json.Unmarshal([]byte(siteInfo.Content), resp) + return resp, nil } -func (s *SiteInfoService) GetSiteInterface(ctx context.Context) (resp schema.SiteInterfaceResp, err error) { - var ( - siteType = "interface" - siteInfo *entity.SiteInfo - exist bool - ) - resp = schema.SiteInterfaceResp{} - - siteInfo, exist, err = s.siteInfoRepo.GetByType(ctx, siteType) - if !exist { - return +// GetSiteInterface get site info interface +func (s *SiteInfoService) GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceResp, err error) { + siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeInterface) + if err != nil { + return nil, err } - - _ = json.Unmarshal([]byte(siteInfo.Content), &resp) - return + if !exist { + return nil, errors.BadRequest(reason.SiteInfoNotFound) + } + resp = &schema.SiteInterfaceResp{} + _ = json.Unmarshal([]byte(siteInfo.Content), resp) + return resp, nil } func (s *SiteInfoService) SaveSiteGeneral(ctx context.Context, req schema.SiteGeneralReq) (err error) { + req.FormatSiteUrl() var ( siteType = "general" content []byte @@ -77,10 +75,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 @@ -96,13 +93,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 } diff --git a/internal/service/siteinfo_common/siteinfo.go b/internal/service/siteinfo_common/siteinfo.go index ff9066b0..1c501d2d 100644 --- a/internal/service/siteinfo_common/siteinfo.go +++ b/internal/service/siteinfo_common/siteinfo.go @@ -2,6 +2,7 @@ package siteinfo_common import ( "context" + "github.com/answerdev/answer/internal/entity" ) diff --git a/internal/service/siteinfo_common/siteinfo_service.go b/internal/service/siteinfo_common/siteinfo_service.go new file mode 100644 index 00000000..5dd565d0 --- /dev/null +++ b/internal/service/siteinfo_common/siteinfo_service.go @@ -0,0 +1,50 @@ +package siteinfo_common + +import ( + "context" + "encoding/json" + + "github.com/answerdev/answer/internal/base/constant" + "github.com/answerdev/answer/internal/base/reason" + "github.com/answerdev/answer/internal/schema" + "github.com/segmentfault/pacman/errors" +) + +type SiteInfoCommonService struct { + siteInfoRepo SiteInfoRepo +} + +func NewSiteInfoCommonService(siteInfoRepo SiteInfoRepo) *SiteInfoCommonService { + return &SiteInfoCommonService{ + siteInfoRepo: siteInfoRepo, + } +} + +// GetSiteGeneral get site info general +func (s *SiteInfoCommonService) GetSiteGeneral(ctx context.Context) (resp *schema.SiteGeneralResp, err error) { + siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeGeneral) + if err != nil { + return nil, err + } + if !exist { + return nil, errors.BadRequest(reason.SiteInfoNotFound) + } + + resp = &schema.SiteGeneralResp{} + _ = json.Unmarshal([]byte(siteInfo.Content), resp) + return resp, nil +} + +// GetSiteInterface get site info interface +func (s *SiteInfoCommonService) GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceResp, err error) { + siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeInterface) + if err != nil { + return nil, err + } + if !exist { + return nil, errors.BadRequest(reason.SiteInfoNotFound) + } + resp = &schema.SiteInterfaceResp{} + _ = json.Unmarshal([]byte(siteInfo.Content), resp) + return resp, nil +} diff --git a/internal/service/tag/tag_service.go b/internal/service/tag/tag_service.go index cf6fc02e..6384eb82 100644 --- a/internal/service/tag/tag_service.go +++ b/internal/service/tag/tag_service.go @@ -3,9 +3,8 @@ package tag import ( "context" "encoding/json" - "strings" - "github.com/answerdev/answer/internal/service/revision_common" + "github.com/answerdev/answer/pkg/htmltext" "github.com/answerdev/answer/internal/base/pager" "github.com/answerdev/answer/internal/base/reason" @@ -344,12 +343,13 @@ func (ts *TagService) GetTagWithPage(ctx context.Context, req *schema.GetTagWith resp := make([]*schema.GetTagPageResp, 0) for _, tag := range tags { + excerpt := htmltext.FetchExcerpt(tag.ParsedText, "...", 240) resp = append(resp, &schema.GetTagPageResp{ TagID: tag.ID, SlugName: tag.SlugName, DisplayName: tag.DisplayName, - OriginalText: cutOutTagParsedText(tag.OriginalText), - ParsedText: cutOutTagParsedText(tag.ParsedText), + OriginalText: excerpt, + ParsedText: excerpt, FollowCount: tag.FollowCount, QuestionCount: tag.QuestionCount, IsFollower: ts.checkTagIsFollow(ctx, req.UserID, tag.ID), @@ -371,12 +371,3 @@ func (ts *TagService) checkTagIsFollow(ctx context.Context, userID, tagID string } return followed } - -func cutOutTagParsedText(parsedText string) string { - parsedText = strings.TrimSpace(parsedText) - idx := strings.Index(parsedText, "\n") - if idx >= 0 { - parsedText = parsedText[0:idx] - } - return parsedText -} diff --git a/internal/service/uploader/upload.go b/internal/service/uploader/upload.go index fab199e3..1d6d2491 100644 --- a/internal/service/uploader/upload.go +++ b/internal/service/uploader/upload.go @@ -12,6 +12,7 @@ import ( "github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/service/service_config" + "github.com/answerdev/answer/internal/service/siteinfo_common" "github.com/answerdev/answer/pkg/dir" "github.com/answerdev/answer/pkg/uid" "github.com/disintegration/imaging" @@ -27,11 +28,13 @@ const ( // UploaderService user service type UploaderService struct { - serviceConfig *service_config.ServiceConfig + serviceConfig *service_config.ServiceConfig + siteInfoService *siteinfo_common.SiteInfoCommonService } // NewUploaderService new upload service -func NewUploaderService(serviceConfig *service_config.ServiceConfig) *UploaderService { +func NewUploaderService(serviceConfig *service_config.ServiceConfig, + siteInfoService *siteinfo_common.SiteInfoCommonService) *UploaderService { err := dir.CreateDirIfNotExist(filepath.Join(serviceConfig.UploadPath, avatarSubPath)) if err != nil { panic(err) @@ -41,7 +44,8 @@ func NewUploaderService(serviceConfig *service_config.ServiceConfig) *UploaderSe panic(err) } return &UploaderService{ - serviceConfig: serviceConfig, + serviceConfig: serviceConfig, + siteInfoService: siteInfoService, } } @@ -122,10 +126,14 @@ func (us *UploaderService) UploadPostFile(ctx *gin.Context, file *multipart.File func (us *UploaderService) uploadFile(ctx *gin.Context, file *multipart.FileHeader, fileSubPath string) ( url string, err error) { + siteGeneral, err := us.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + return "", err + } filePath := path.Join(us.serviceConfig.UploadPath, fileSubPath) if err := ctx.SaveUploadedFile(file, filePath); err != nil { return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() } - url = fmt.Sprintf("%s/uploads/%s", us.serviceConfig.WebHost, fileSubPath) + url = fmt.Sprintf("%s/uploads/%s", siteGeneral.SiteUrl, fileSubPath) return url, nil } diff --git a/internal/service/user_common/user.go b/internal/service/user_common/user.go index 5cc194b6..92140250 100644 --- a/internal/service/user_common/user.go +++ b/internal/service/user_common/user.go @@ -15,12 +15,14 @@ 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) BatchGetByID(ctx context.Context, ids []string) ([]*entity.User, error) GetByUsername(ctx context.Context, username string) (userInfo *entity.User, exist bool, err error) GetByEmail(ctx context.Context, email string) (userInfo *entity.User, exist bool, err error) + GetUserCount(ctx context.Context) (count int64, err error) } // UserCommon user service diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 17341095..8673c3d2 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -11,12 +11,14 @@ 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" "github.com/answerdev/answer/internal/service/auth" "github.com/answerdev/answer/internal/service/export" "github.com/answerdev/answer/internal/service/service_config" + "github.com/answerdev/answer/internal/service/siteinfo_common" usercommon "github.com/answerdev/answer/internal/service/user_common" "github.com/answerdev/answer/pkg/checker" "github.com/google/uuid" @@ -29,11 +31,12 @@ import ( // UserService user service type UserService struct { - userRepo usercommon.UserRepo - userActivity activity.UserActiveActivityRepo - serviceConfig *service_config.ServiceConfig - emailService *export.EmailService - authService *auth.AuthService + userRepo usercommon.UserRepo + userActivity activity.UserActiveActivityRepo + serviceConfig *service_config.ServiceConfig + emailService *export.EmailService + authService *auth.AuthService + siteInfoService *siteinfo_common.SiteInfoCommonService } func NewUserService(userRepo usercommon.UserRepo, @@ -41,13 +44,15 @@ func NewUserService(userRepo usercommon.UserRepo, emailService *export.EmailService, authService *auth.AuthService, serviceConfig *service_config.ServiceConfig, + siteInfoService *siteinfo_common.SiteInfoCommonService, ) *UserService { return &UserService{ - userRepo: userRepo, - userActivity: userActivity, - emailService: emailService, - serviceConfig: serviceConfig, - authService: authService, + userRepo: userRepo, + userActivity: userActivity, + emailService: emailService, + serviceConfig: serviceConfig, + authService: authService, + siteInfoService: siteInfoService, } } @@ -66,35 +71,6 @@ func (us *UserService) GetUserInfoByUserID(ctx context.Context, token, userID st return resp, nil } -// GetUserStatus get user info by user id -func (us *UserService) GetUserStatus(ctx context.Context, userID, token string) (resp *schema.GetUserStatusResp, err error) { - resp = &schema.GetUserStatusResp{} - if len(userID) == 0 { - return resp, nil - } - userInfo, exist, err := us.userRepo.GetByUserID(ctx, userID) - if err != nil { - return nil, err - } - if !exist { - return nil, errors.BadRequest(reason.UserNotFound) - } - - userCacheInfo := &entity.UserCacheInfo{ - UserID: userID, - UserStatus: userInfo.Status, - EmailStatus: userInfo.MailStatus, - } - err = us.authService.UpdateUserCacheInfo(ctx, token, userCacheInfo) - if err != nil { - return nil, err - } - resp = &schema.GetUserStatusResp{ - Status: schema.UserStatusShow[userInfo.Status], - } - return resp, nil -} - func (us *UserService) GetOtherUserInfoByUsername(ctx context.Context, username string) ( resp *schema.GetOtherUserInfoResp, err error, ) { @@ -168,7 +144,7 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet UserID: userInfo.ID, } code := uuid.NewString() - verifyEmailURL := fmt.Sprintf("%s/users/password-reset?code=%s", us.serviceConfig.WebHost, code) + verifyEmailURL := fmt.Sprintf("%s/users/password-reset?code=%s", us.getSiteUrl(ctx), code) title, body, err := us.emailService.PassResetTemplate(ctx, verifyEmailURL) if err != nil { return "", err @@ -283,6 +259,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, @@ -320,7 +308,7 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo UserID: userInfo.ID, } code := uuid.NewString() - verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.serviceConfig.WebHost, code) + verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code) title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL) if err != nil { return nil, err @@ -363,7 +351,7 @@ func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) e UserID: userInfo.ID, } code := uuid.NewString() - verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.serviceConfig.WebHost, code) + verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code) title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL) if err != nil { return err @@ -489,21 +477,26 @@ func (us *UserService) encryptPassword(ctx context.Context, Pass string) (string } // UserChangeEmailSendCode user change email verification -func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.UserChangeEmailSendCodeReq) error { +func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.UserChangeEmailSendCodeReq) ( + resp *schema.UserVerifyEmailErrorResponse, err error) { userInfo, exist, err := us.userRepo.GetByUserID(ctx, req.UserID) if err != nil { - return err + return nil, err } if !exist { - return errors.BadRequest(reason.UserNotFound) + return nil, errors.BadRequest(reason.UserNotFound) } _, exist, err = us.userRepo.GetByEmail(ctx, req.Email) if err != nil { - return err + return nil, err } if exist { - return errors.BadRequest(reason.EmailDuplicate) + resp = &schema.UserVerifyEmailErrorResponse{ + Key: "e_mail", + Value: reason.EmailDuplicate, + } + return resp, errors.BadRequest(reason.EmailDuplicate) } data := &schema.EmailCodeContent{ @@ -512,19 +505,19 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema. } code := uuid.NewString() var title, body string - verifyEmailURL := fmt.Sprintf("%s/users/confirm-new-email?code=%s", us.serviceConfig.WebHost, code) + verifyEmailURL := fmt.Sprintf("%s/users/confirm-new-email?code=%s", us.getSiteUrl(ctx), code) if userInfo.MailStatus == entity.EmailStatusToBeVerified { title, body, err = us.emailService.RegisterTemplate(ctx, verifyEmailURL) } else { title, body, err = us.emailService.ChangeEmailTemplate(ctx, verifyEmailURL) } if err != nil { - return err + return nil, err } log.Infof("send email confirmation %s", verifyEmailURL) go us.emailService.Send(context.Background(), req.Email, title, body, code, data.ToJSONString()) - return nil + return nil, nil } // UserChangeEmailVerify user change email verify code @@ -560,3 +553,13 @@ func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string } return nil } + +// getSiteUrl get site url +func (us *UserService) getSiteUrl(ctx context.Context) string { + siteGeneral, err := us.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general failed: %s", err) + return "" + } + return siteGeneral.SiteUrl +} diff --git a/pkg/dir/dir.go b/pkg/dir/dir.go index 559f0d1c..808bef6e 100644 --- a/pkg/dir/dir.go +++ b/pkg/dir/dir.go @@ -1,6 +1,10 @@ package dir -import "os" +import ( + "fmt" + "os" + "path/filepath" +) func CreateDirIfNotExist(path string) error { return os.MkdirAll(path, os.ModePerm) @@ -15,3 +19,32 @@ func CheckFileExist(path string) bool { f, err := os.Stat(path) return err == nil && !f.IsDir() } + +func DirSize(path string) (int64, error) { + var size int64 + err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if !info.IsDir() { + size += info.Size() + } + return err + }) + return size, err +} + +func FormatFileSize(fileSize int64) (size string) { + if fileSize < 1024 { + //return strconv.FormatInt(fileSize, 10) + "B" + return fmt.Sprintf("%.2f B", float64(fileSize)/float64(1)) + } else if fileSize < (1024 * 1024) { + return fmt.Sprintf("%.2f KB", float64(fileSize)/float64(1024)) + } else if fileSize < (1024 * 1024 * 1024) { + return fmt.Sprintf("%.2f MB", float64(fileSize)/float64(1024*1024)) + } else if fileSize < (1024 * 1024 * 1024 * 1024) { + return fmt.Sprintf("%.2f GB", float64(fileSize)/float64(1024*1024*1024)) + } else if fileSize < (1024 * 1024 * 1024 * 1024 * 1024) { + return fmt.Sprintf("%.2f TB", float64(fileSize)/float64(1024*1024*1024*1024)) + } else { //if fileSize < (1024 * 1024 * 1024 * 1024 * 1024 * 1024) + return fmt.Sprintf("%.2f EB", float64(fileSize)/float64(1024*1024*1024*1024*1024)) + } + +} diff --git a/pkg/htmltext/htmltext.go b/pkg/htmltext/htmltext.go new file mode 100644 index 00000000..e0463e10 --- /dev/null +++ b/pkg/htmltext/htmltext.go @@ -0,0 +1,60 @@ +package htmltext + +import ( + "github.com/grokify/html-strip-tags-go" + "regexp" + "strings" +) + +// ClearText clear HTML, get the clear text +func ClearText(html string) (text string) { + if len(html) == 0 { + text = html + return + } + + var ( + re *regexp.Regexp + codeReg = `(?ism)<(pre)>.*<\/pre>` + codeRepl = "{code...}" + linkReg = `(?ism)(.*)?<\/a>` + linkRepl = " [$1] " + spaceReg = ` +` + spaceRepl = " " + ) + re = regexp.MustCompile(codeReg) + html = re.ReplaceAllString(html, codeRepl) + + re = regexp.MustCompile(linkReg) + html = re.ReplaceAllString(html, linkRepl) + + text = strings.NewReplacer( + "\n", " ", + "\r", " ", + "\t", " ", + ).Replace(strip.StripTags(html)) + + // replace multiple spaces to one space + re = regexp.MustCompile(spaceReg) + text = strings.TrimSpace(re.ReplaceAllString(text, spaceRepl)) + return +} + +// FetchExcerpt return the excerpt from the HTML string +func FetchExcerpt(html, trimMarker string, limit int) (text string) { + if len(html) == 0 { + text = html + return + } + + text = ClearText(html) + runeText := []rune(text) + if len(runeText) <= limit { + text = string(runeText) + } else { + text = string(runeText[0:limit]) + } + + text += trimMarker + return +} diff --git a/pkg/htmltext/htmltext_test.go b/pkg/htmltext/htmltext_test.go new file mode 100644 index 00000000..a320ef12 --- /dev/null +++ b/pkg/htmltext/htmltext_test.go @@ -0,0 +1,51 @@ +package htmltext + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestClearText(t *testing.T) { + var ( + expected, + clearedText string + ) + + // test code clear text + expected = "hello{code...}" + clearedText = ClearText("

hello

var a = \"good\"

") + assert.Equal(t, expected, clearedText) + + // test link clear text + expected = "hello [example.com]" + clearedText = ClearText("

hello example.com

") + assert.Equal(t, expected, clearedText) + clearedText = ClearText("

helloexample.com

") + assert.Equal(t, expected, clearedText) + + expected = "hello world" + clearedText = ClearText("
hello
\n
world
") + assert.Equal(t, expected, clearedText) +} + +func TestFetchExcerpt(t *testing.T) { + var ( + expected, + text string + ) + + // test english string + expected = "hello..." + text = FetchExcerpt("

hello world

", "...", 5) + assert.Equal(t, expected, text) + + // test mixed string + expected = "hello你好..." + text = FetchExcerpt("

hello你好world

", "...", 7) + assert.Equal(t, expected, text) + + // test mixed string with emoticon + expected = "hello你好😂..." + text = FetchExcerpt("

hello你好😂world

", "...", 8) + assert.Equal(t, expected, text) +} diff --git a/pkg/writer/writer.go b/pkg/writer/writer.go new file mode 100644 index 00000000..d2b6c440 --- /dev/null +++ b/pkg/writer/writer.go @@ -0,0 +1,31 @@ +package writer + +import ( + "bufio" + "os" +) + +// ReplaceFile remove old file and write new file +func ReplaceFile(filePath, content string) error { + _ = os.Remove(filePath) + return WriteFile(filePath, content) +} + +// WriteFile write file to path +func WriteFile(filePath, content string) error { + file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0o666) + if err != nil { + return err + } + defer func() { + _ = file.Close() + }() + writer := bufio.NewWriter(file) + if _, err := writer.WriteString(content); err != nil { + return err + } + if err := writer.Flush(); err != nil { + return err + } + return nil +} diff --git a/script/entrypoint.sh b/script/entrypoint.sh index c66528d8..41ce1c7a 100755 --- a/script/entrypoint.sh +++ b/script/entrypoint.sh @@ -1,3 +1,4 @@ #!/bin/bash /usr/bin/answer init -/usr/bin/answer run -c /data/conf/config.yaml +/usr/bin/answer upgrade +/usr/bin/answer run -C /data/ diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index 877a0039..6ecb4b49 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -1,4 +1,5 @@ module.exports = { + root: true, env: { browser: true, es2021: true, @@ -19,7 +20,8 @@ module.exports = { }, ecmaVersion: 'latest', sourceType: 'module', - project: './tsconfig.json', + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], }, plugins: ['react', '@typescript-eslint'], rules: { @@ -64,7 +66,7 @@ module.exports = { position: 'before', }, { - pattern: '@answer/**', + pattern: '@/**', group: 'internal', }, { diff --git a/ui/commitlint.config.js b/ui/commitlint.config.js index 84dcb122..4944db0e 100644 --- a/ui/commitlint.config.js +++ b/ui/commitlint.config.js @@ -1,3 +1,3 @@ module.exports = { - extends: ['@commitlint/config-conventional'], + extends: ['@commitlint/routes-conventional'], }; diff --git a/ui/config-overrides.js b/ui/config-overrides.js index 06464361..0a5deeff 100644 --- a/ui/config-overrides.js +++ b/ui/config-overrides.js @@ -1,36 +1,51 @@ -const path = require('path'); +const { + addWebpackModuleRule, + addWebpackAlias +} = require("customize-cra"); + +const path = require("path"); +const i18nPath = path.resolve(__dirname, "../i18n"); module.exports = { - webpack: function (config, env) { - if (env === 'production') { + webpack: function(config, env) { + if (env === "production") { config.output.publicPath = process.env.REACT_APP_PUBLIC_PATH; } - 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'), - }; + + addWebpackAlias({ + ["@"]: path.resolve(__dirname, "src"), + "@i18n": i18nPath + })(config); + + addWebpackModuleRule({ + test: /\.ya?ml$/, + use: "yaml-loader" + })(config); + + // add i18n dir to ModuleScopePlugin allowedPaths + const moduleScopePlugin = config.resolve.plugins.find(_ => _.constructor.name === "ModuleScopePlugin"); + if (moduleScopePlugin) { + moduleScopePlugin.allowedPaths.push(i18nPath); + } return config; }, - - devServer: function (configFunction) { - return function (proxy, allowedHost) { + devServer: function(configFunction) { + return function(proxy, allowedHost) { const config = configFunction(proxy, allowedHost); config.proxy = { - '/answer': { - target: 'http://10.0.10.98:2060', + "/answer": { + target: "http://10.0.10.98:2060", changeOrigin: true, - secure: false, + secure: false }, + "/installation": { + target: "http://10.0.10.98:2060", + changeOrigin: true, + secure: false + } }; return config; }; - }, + } }; diff --git a/ui/package.json b/ui/package.json index 069456ef..c09fac27 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,12 +10,12 @@ "build:prod": "env-cmd -f .env.production react-app-rewired build", "build": "env-cmd -f .env react-app-rewired build", "test": "react-app-rewired test", - "eject": "react-scripts eject", "lint": "eslint . --cache --fix --ext .ts,.tsx", "prepare": "cd .. && husky install", "cz": "cz", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", - "prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"" + "prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", + "preinstall": "node ./scripts/preinstall.js" }, "config": { "commitizen": { @@ -101,7 +101,8 @@ "sass": "^1.54.4", "tsconfig-paths-webpack-plugin": "^4.0.0", "typescript": "*", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "yaml-loader": "^0.8.0" }, "packageManager": "pnpm@7.9.5", "engines": { @@ -109,4 +110,4 @@ "pnpm": ">=7" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 505ff738..98ebd589 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -77,6 +77,7 @@ specifiers: tsconfig-paths-webpack-plugin: ^4.0.0 typescript: '*' web-vitals: ^2.1.4 + yaml-loader: ^0.8.0 zustand: ^4.1.1 dependencies: @@ -159,6 +160,7 @@ devDependencies: tsconfig-paths-webpack-plugin: 4.0.0 typescript: 4.8.3 web-vitals: 2.1.4 + yaml-loader: 0.8.0 packages: @@ -7040,6 +7042,10 @@ packages: filelist: 1.0.4 minimatch: 3.1.2 + /javascript-stringify/2.1.0: + resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} + dev: true + /jest-changed-files/27.5.1: resolution: {integrity: sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -11682,6 +11688,15 @@ packages: /yallist/4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + /yaml-loader/0.8.0: + resolution: {integrity: sha512-LjeKnTzVBKWiQBeE2L9ssl6WprqaUIxCSNs5tle8PaDydgu3wVFXTbMfsvF2MSErpy9TDVa092n4q6adYwJaWg==} + engines: {node: '>= 12.13'} + dependencies: + javascript-stringify: 2.1.0 + loader-utils: 2.0.2 + yaml: 2.1.1 + dev: true + /yaml/1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} diff --git a/ui/scripts/preinstall.js b/ui/scripts/preinstall.js new file mode 100644 index 00000000..076ad606 --- /dev/null +++ b/ui/scripts/preinstall.js @@ -0,0 +1,5 @@ +// There is a bug when using npm to install: the execution of preinstall is after install, so when this prompt is displayed, the dependent packages have already been installed. +if (!/pnpm/.test(process.env.npm_execpath)) { + console.warn(`\u001b[33mThis repository requires using pnpm as the package manager for scripts to work properly.\u001b[39m\n`) + process.exit(1) +} \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5d4f6925..92e9da21 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,8 +1,10 @@ import { RouterProvider } from 'react-router-dom'; -import router from '@/router'; +import './i18n/init'; +import { routes, createBrowserRouter } from '@/router'; function App() { + const router = createBrowserRouter(routes); return ; } diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 3e577515..1bf1f355 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -1,9 +1,10 @@ -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 LANG_RESOURCE_STORAGE_KEY = '_a_lang_r_'; +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 +57,494 @@ export const ADMIN_NAV_MENUS = [ child: [{ name: 'general' }, { name: 'interface' }, { name: 'smtp' }], }, ]; + +export const TIMEZONES = [ + { + label: 'Africa', + options: [ + { value: 'Africa/Abidjan', label: 'Abidjan' }, + { value: 'Africa/Accra', label: 'Accra' }, + { value: 'Africa/Addis_Ababa', label: 'Addis Ababa' }, + { value: 'Africa/Algiers', label: 'Algiers' }, + { value: 'Africa/Asmara', label: 'Asmara' }, + { value: 'Africa/Bamako', label: 'Bamako' }, + { value: 'Africa/Bangui', label: 'Bangui' }, + { value: 'Africa/Banjul', label: 'Banjul' }, + { value: 'Africa/Bissau', label: 'Bissau' }, + { value: 'Africa/Blantyre', label: 'Blantyre' }, + { value: 'Africa/Brazzaville', label: 'Brazzaville' }, + { value: 'Africa/Bujumbura', label: 'Bujumbura' }, + { value: 'Africa/Cairo', label: 'Cairo' }, + { value: 'Africa/Casablanca', label: 'Casablanca' }, + { value: 'Africa/Ceuta', label: 'Ceuta' }, + { value: 'Africa/Conakry', label: 'Conakry' }, + { value: 'Africa/Dakar', label: 'Dakar' }, + { value: 'Africa/Dar_es_Salaam', label: 'Dar es Salaam' }, + { value: 'Africa/Djibouti', label: 'Djibouti' }, + { value: 'Africa/Douala', label: 'Douala' }, + { value: 'Africa/El_Aaiun', label: 'El Aaiun' }, + { value: 'Africa/Freetown', label: 'Freetown' }, + { value: 'Africa/Gaborone', label: 'Gaborone' }, + { value: 'Africa/Harare', label: 'Harare' }, + { value: 'Africa/Johannesburg', label: 'Johannesburg' }, + { value: 'Africa/Juba', label: 'Juba' }, + { value: 'Africa/Kampala', label: 'Kampala' }, + { value: 'Africa/Khartoum', label: 'Khartoum' }, + { value: 'Africa/Kigali', label: 'Kigali' }, + { value: 'Africa/Kinshasa', label: 'Kinshasa' }, + { value: 'Africa/Lagos', label: 'Lagos' }, + { value: 'Africa/Libreville', label: 'Libreville' }, + { value: 'Africa/Lome', label: 'Lome' }, + { value: 'Africa/Luanda', label: 'Luanda' }, + { value: 'Africa/Lubumbashi', label: 'Lubumbashi' }, + { value: 'Africa/Lusaka', label: 'Lusaka' }, + { value: 'Africa/Malabo', label: 'Malabo' }, + { value: 'Africa/Maputo', label: 'Maputo' }, + { value: 'Africa/Maseru', label: 'Maseru' }, + { value: 'Africa/Mbabane', label: 'Mbabane' }, + { value: 'Africa/Mogadishu', label: 'Mogadishu' }, + { value: 'Africa/Monrovia', label: 'Monrovia' }, + { value: 'Africa/Nairobi', label: 'Nairobi' }, + { value: 'Africa/Ndjamena', label: 'Ndjamena' }, + { value: 'Africa/Niamey', label: 'Niamey' }, + { value: 'Africa/Nouakchott', label: 'Nouakchott' }, + { value: 'Africa/Ouagadougou', label: 'Ouagadougou' }, + { value: 'Africa/Porto-Novo', label: 'Porto-Novo' }, + { value: 'Africa/Sao_Tome', label: 'Sao Tome' }, + { value: 'Africa/Tripoli', label: 'Tripoli' }, + { value: 'Africa/Tunis', label: 'Tunis' }, + { value: 'Africa/Windhoek', label: 'Windhoek' }, + ], + }, + { + label: 'America', + options: [ + { value: 'America/Adak', label: 'Adak' }, + { value: 'America/Anchorage', label: 'Anchorage' }, + { value: 'America/Anguilla', label: 'Anguilla' }, + { value: 'America/Antigua', label: 'Antigua' }, + { value: 'America/Araguaina', label: 'Araguaina' }, + { + value: 'America/Argentina/Buenos_Aires', + label: 'Argentina - Buenos Aires', + }, + { value: 'America/Argentina/Catamarca', label: 'Argentina - Catamarca' }, + { value: 'America/Argentina/Cordoba', label: 'Argentina - Cordoba' }, + { value: 'America/Argentina/Jujuy', label: 'Argentina - Jujuy' }, + { value: 'America/Argentina/La_Rioja', label: 'Argentina - La Rioja' }, + { value: 'America/Argentina/Mendoza', label: 'Argentina - Mendoza' }, + { + value: 'America/Argentina/Rio_Gallegos', + label: 'Argentina - Rio Gallegos', + }, + { value: 'America/Argentina/Salta', label: 'Argentina - Salta' }, + { value: 'America/Argentina/San_Juan', label: 'Argentina - San Juan' }, + { value: 'America/Argentina/San_Luis', label: 'Argentina - San Luis' }, + { value: 'America/Argentina/Tucuman', label: 'Argentina - Tucuman' }, + { value: 'America/Argentina/Ushuaia', label: 'Argentina - Ushuaia' }, + { value: 'America/Aruba', label: 'Aruba' }, + { value: 'America/Asuncion', label: 'Asuncion' }, + { value: 'America/Atikokan', label: 'Atikokan' }, + { value: 'America/Bahia', label: 'Bahia' }, + { value: 'America/Bahia_Banderas', label: 'Bahia Banderas' }, + { value: 'America/Barbados', label: 'Barbados' }, + { value: 'America/Belem', label: 'Belem' }, + { value: 'America/Belize', label: 'Belize' }, + { value: 'America/Blanc-Sablon', label: 'Blanc-Sablon' }, + { value: 'America/Boa_Vista', label: 'Boa Vista' }, + { value: 'America/Bogota', label: 'Bogota' }, + { value: 'America/Boise', label: 'Boise' }, + { value: 'America/Cambridge_Bay', label: 'Cambridge Bay' }, + { value: 'America/Campo_Grande', label: 'Campo Grande' }, + { value: 'America/Cancun', label: 'Cancun' }, + { value: 'America/Caracas', label: 'Caracas' }, + { value: 'America/Cayenne', label: 'Cayenne' }, + { value: 'America/Cayman', label: 'Cayman' }, + { value: 'America/Chicago', label: 'Chicago' }, + { value: 'America/Chihuahua', label: 'Chihuahua' }, + { value: 'America/Costa_Rica', label: 'Costa Rica' }, + { value: 'America/Creston', label: 'Creston' }, + { value: 'America/Cuiaba', label: 'Cuiaba' }, + { value: 'America/Curacao', label: 'Curacao' }, + { value: 'America/Danmarkshavn', label: 'Danmarkshavn' }, + { value: 'America/Dawson', label: 'Dawson' }, + { value: 'America/Dawson_Creek', label: 'Dawson Creek' }, + { value: 'America/Denver', label: 'Denver' }, + { value: 'America/Detroit', label: 'Detroit' }, + { value: 'America/Dominica', label: 'Dominica' }, + { value: 'America/Edmonton', label: 'Edmonton' }, + { value: 'America/Eirunepe', label: 'Eirunepe' }, + { value: 'America/El_Salvador', label: 'El Salvador' }, + { value: 'America/Fort_Nelson', label: 'Fort Nelson' }, + { value: 'America/Fortaleza', label: 'Fortaleza' }, + { value: 'America/Glace_Bay', label: 'Glace Bay' }, + { value: 'America/Godthab', label: 'Godthab' }, + { value: 'America/Goose_Bay', label: 'Goose Bay' }, + { value: 'America/Grand_Turk', label: 'Grand Turk' }, + { value: 'America/Grenada', label: 'Grenada' }, + { value: 'America/Guadeloupe', label: 'Guadeloupe' }, + { value: 'America/Guatemala', label: 'Guatemala' }, + { value: 'America/Guayaquil', label: 'Guayaquil' }, + { value: 'America/Guyana', label: 'Guyana' }, + { value: 'America/Halifax', label: 'Halifax' }, + { value: 'America/Havana', label: 'Havana' }, + { value: 'America/Hermosillo', label: 'Hermosillo' }, + { + value: 'America/Indiana/Indianapolis', + label: 'Indiana - Indianapolis', + }, + { value: 'America/Indiana/Knox', label: 'Indiana - Knox' }, + { value: 'America/Indiana/Marengo', label: 'Indiana - Marengo' }, + { value: 'America/Indiana/Petersburg', label: 'Indiana - Petersburg' }, + { value: 'America/Indiana/Tell_City', label: 'Indiana - Tell City' }, + { value: 'America/Indiana/Vevay', label: 'Indiana - Vevay' }, + { value: 'America/Indiana/Vincennes', label: 'Indiana - Vincennes' }, + { value: 'America/Indiana/Winamac', label: 'Indiana - Winamac' }, + { value: 'America/Inuvik', label: 'Inuvik' }, + { value: 'America/Iqaluit', label: 'Iqaluit' }, + { value: 'America/Jamaica', label: 'Jamaica' }, + { value: 'America/Juneau', label: 'Juneau' }, + { value: 'America/Kentucky/Louisville', label: 'Kentucky - Louisville' }, + { value: 'America/Kentucky/Monticello', label: 'Kentucky - Monticello' }, + { value: 'America/Kralendijk', label: 'Kralendijk' }, + { value: 'America/La_Paz', label: 'La Paz' }, + { value: 'America/Lima', label: 'Lima' }, + { value: 'America/Los_Angeles', label: 'Los Angeles' }, + { value: 'America/Lower_Princes', label: 'Lower Princes' }, + { value: 'America/Maceio', label: 'Maceio' }, + { value: 'America/Managua', label: 'Managua' }, + { value: 'America/Manaus', label: 'Manaus' }, + { value: 'America/Marigot', label: 'Marigot' }, + { value: 'America/Martinique', label: 'Martinique' }, + { value: 'America/Matamoros', label: 'Matamoros' }, + { value: 'America/Mazatlan', label: 'Mazatlan' }, + { value: 'America/Miquelon', label: 'Miquelon' }, + { value: 'America/Moncton', label: 'Moncton' }, + { value: 'America/Monterrey', label: 'Monterrey' }, + { value: 'America/Montevideo', label: 'Montevideo' }, + { value: 'America/Montserrat', label: 'Montserrat' }, + { value: 'America/Nassau', label: 'Nassau' }, + { value: 'America/New_York', label: 'New York' }, + { value: 'America/Nipigon', label: 'Nipigon' }, + { value: 'America/Nome', label: 'Nome' }, + { value: 'America/Noronha', label: 'Noronha' }, + { value: 'America/North_Dakota/Beulah', label: 'North Dakota - Beulah' }, + { value: 'America/North_Dakota/Center', label: 'North Dakota - Center' }, + { + value: 'America/North_Dakota/New_Salem', + label: 'North Dakota - New Salem', + }, + { value: 'America/Ojinaga', label: 'Ojinaga' }, + { value: 'America/Panama', label: 'Panama' }, + { value: 'America/Pangnirtung', label: 'Pangnirtung' }, + { value: 'America/Paramaribo', label: 'Paramaribo' }, + { value: 'America/Phoenix', label: 'Phoenix' }, + { value: 'America/Port-au-Prince', label: 'Port-au-Prince' }, + { value: 'America/Port_of_Spain', label: 'Port of Spain' }, + { value: 'America/Porto_Velho', label: 'Porto Velho' }, + { value: 'America/Puerto_Rico', label: 'Puerto Rico' }, + { value: 'America/Punta_Arenas', label: 'Punta Arenas' }, + { value: 'America/Rainy_River', label: 'Rainy River' }, + { value: 'America/Rankin_Inlet', label: 'Rankin Inlet' }, + { value: 'America/Recife', label: 'Recife' }, + { value: 'America/Regina', label: 'Regina' }, + { value: 'America/Resolute', label: 'Resolute' }, + { value: 'America/Rio_Branco', label: 'Rio Branco' }, + { value: 'America/Santarem', label: 'Santarem' }, + { value: 'America/Santiago', label: 'Santiago' }, + { value: 'America/Santo_Domingo', label: 'Santo Domingo' }, + { value: 'America/Sao_Paulo', label: 'Sao Paulo' }, + { value: 'America/Scoresbysund', label: 'Scoresbysund' }, + { value: 'America/Sitka', label: 'Sitka' }, + { value: 'America/St_Barthelemy', label: 'St Barthelemy' }, + { value: 'America/St_Johns', label: 'St Johns' }, + { value: 'America/St_Kitts', label: 'St Kitts' }, + { value: 'America/St_Lucia', label: 'St Lucia' }, + { value: 'America/St_Thomas', label: 'St Thomas' }, + { value: 'America/St_Vincent', label: 'St Vincent' }, + { value: 'America/Swift_Current', label: 'Swift Current' }, + { value: 'America/Tegucigalpa', label: 'Tegucigalpa' }, + { value: 'America/Thule', label: 'Thule' }, + { value: 'America/Thunder_Bay', label: 'Thunder Bay' }, + { value: 'America/Tijuana', label: 'Tijuana' }, + { value: 'America/Toronto', label: 'Toronto' }, + { value: 'America/Tortola', label: 'Tortola' }, + { value: 'America/Vancouver', label: 'Vancouver' }, + { value: 'America/Whitehorse', label: 'Whitehorse' }, + { value: 'America/Winnipeg', label: 'Winnipeg' }, + { value: 'America/Yakutat', label: 'Yakutat' }, + { value: 'America/Yellowknife', label: 'Yellowknife' }, + ], + }, + { + label: 'Antarctica', + options: [ + { value: 'Antarctica/Casey', label: 'Casey' }, + { value: 'Antarctica/Davis', label: 'Davis' }, + { value: 'Antarctica/DumontDUrville', label: 'DumontDUrville' }, + { value: 'Antarctica/Macquarie', label: 'Macquarie' }, + { value: 'Antarctica/Mawson', label: 'Mawson' }, + { value: 'Antarctica/McMurdo', label: 'McMurdo' }, + { value: 'Antarctica/Palmer', label: 'Palmer' }, + { value: 'Antarctica/Rothera', label: 'Rothera' }, + { value: 'Antarctica/Syowa', label: 'Syowa' }, + { value: 'Antarctica/Troll', label: 'Troll' }, + { value: 'Antarctica/Vostok', label: 'Vostok' }, + ], + }, + { + label: 'Arctic', + options: [{ value: 'Arctic/Longyearbyen', label: 'Longyearbyen' }], + }, + { + label: 'Asia', + options: [ + { value: 'Asia/Aden', label: 'Aden' }, + { value: 'Asia/Almaty', label: 'Almaty' }, + { value: 'Asia/Amman', label: 'Amman' }, + { value: 'Asia/Anadyr', label: 'Anadyr' }, + { value: 'Asia/Aqtau', label: 'Aqtau' }, + { value: 'Asia/Aqtobe', label: 'Aqtobe' }, + { value: 'Asia/Ashgabat', label: 'Ashgabat' }, + { value: 'Asia/Atyrau', label: 'Atyrau' }, + { value: 'Asia/Baghdad', label: 'Baghdad' }, + { value: 'Asia/Bahrain', label: 'Bahrain' }, + { value: 'Asia/Baku', label: 'Baku' }, + { value: 'Asia/Bangkok', label: 'Bangkok' }, + { value: 'Asia/Barnaul', label: 'Barnaul' }, + { value: 'Asia/Beirut', label: 'Beirut' }, + { value: 'Asia/Bishkek', label: 'Bishkek' }, + { value: 'Asia/Brunei', label: 'Brunei' }, + { value: 'Asia/Chita', label: 'Chita' }, + { value: 'Asia/Choibalsan', label: 'Choibalsan' }, + { value: 'Asia/Colombo', label: 'Colombo' }, + { value: 'Asia/Damascus', label: 'Damascus' }, + { value: 'Asia/Dhaka', label: 'Dhaka' }, + { value: 'Asia/Dili', label: 'Dili' }, + { value: 'Asia/Dubai', label: 'Dubai' }, + { value: 'Asia/Dushanbe', label: 'Dushanbe' }, + { value: 'Asia/Famagusta', label: 'Famagusta' }, + { value: 'Asia/Gaza', label: 'Gaza' }, + { value: 'Asia/Hebron', label: 'Hebron' }, + { value: 'Asia/Ho_Chi_Minh', label: 'Ho Chi Minh' }, + { value: 'Asia/Hong_Kong', label: 'Hong Kong' }, + { value: 'Asia/Hovd', label: 'Hovd' }, + { value: 'Asia/Irkutsk', label: 'Irkutsk' }, + { value: 'Asia/Jakarta', label: 'Jakarta' }, + { value: 'Asia/Jayapura', label: 'Jayapura' }, + { value: 'Asia/Jerusalem', label: 'Jerusalem' }, + { value: 'Asia/Kabul', label: 'Kabul' }, + { value: 'Asia/Kamchatka', label: 'Kamchatka' }, + { value: 'Asia/Karachi', label: 'Karachi' }, + { value: 'Asia/Kathmandu', label: 'Kathmandu' }, + { value: 'Asia/Khandyga', label: 'Khandyga' }, + { value: 'Asia/Kolkata', label: 'Kolkata' }, + { value: 'Asia/Krasnoyarsk', label: 'Krasnoyarsk' }, + { value: 'Asia/Kuala_Lumpur', label: 'Kuala Lumpur' }, + { value: 'Asia/Kuching', label: 'Kuching' }, + { value: 'Asia/Kuwait', label: 'Kuwait' }, + { value: 'Asia/Macau', label: 'Macau' }, + { value: 'Asia/Magadan', label: 'Magadan' }, + { value: 'Asia/Makassar', label: 'Makassar' }, + { value: 'Asia/Manila', label: 'Manila' }, + { value: 'Asia/Muscat', label: 'Muscat' }, + { value: 'Asia/Nicosia', label: 'Nicosia' }, + { value: 'Asia/Novokuznetsk', label: 'Novokuznetsk' }, + { value: 'Asia/Novosibirsk', label: 'Novosibirsk' }, + { value: 'Asia/Omsk', label: 'Omsk' }, + { value: 'Asia/Oral', label: 'Oral' }, + { value: 'Asia/Phnom_Penh', label: 'Phnom Penh' }, + { value: 'Asia/Pontianak', label: 'Pontianak' }, + { value: 'Asia/Pyongyang', label: 'Pyongyang' }, + { value: 'Asia/Qatar', label: 'Qatar' }, + { value: 'Asia/Qostanay', label: 'Qostanay' }, + { value: 'Asia/Qyzylorda', label: 'Qyzylorda' }, + { value: 'Asia/Riyadh', label: 'Riyadh' }, + { value: 'Asia/Sakhalin', label: 'Sakhalin' }, + { value: 'Asia/Samarkand', label: 'Samarkand' }, + { value: 'Asia/Seoul', label: 'Seoul' }, + { value: 'Asia/Shanghai', label: 'Shanghai' }, + { value: 'Asia/Singapore', label: 'Singapore' }, + { value: 'Asia/Srednekolymsk', label: 'Srednekolymsk' }, + { value: 'Asia/Taipei', label: 'Taipei' }, + { value: 'Asia/Tashkent', label: 'Tashkent' }, + { value: 'Asia/Tbilisi', label: 'Tbilisi' }, + { value: 'Asia/Tehran', label: 'Tehran' }, + { value: 'Asia/Thimphu', label: 'Thimphu' }, + { value: 'Asia/Tokyo', label: 'Tokyo' }, + { value: 'Asia/Tomsk', label: 'Tomsk' }, + { value: 'Asia/Ulaanbaatar', label: 'Ulaanbaatar' }, + { value: 'Asia/Urumqi', label: 'Urumqi' }, + { value: 'Asia/Ust-Nera', label: 'Ust-Nera' }, + { value: 'Asia/Vientiane', label: 'Vientiane' }, + { value: 'Asia/Vladivostok', label: 'Vladivostok' }, + { value: 'Asia/Yakutsk', label: 'Yakutsk' }, + { value: 'Asia/Yangon', label: 'Yangon' }, + { value: 'Asia/Yekaterinburg', label: 'Yekaterinburg' }, + { value: 'Asia/Yerevan', label: 'Yerevan' }, + ], + }, + { + label: 'Atlantic', + options: [ + { value: 'Atlantic/Azores', label: 'Azores' }, + { value: 'Atlantic/Bermuda', label: 'Bermuda' }, + { value: 'Atlantic/Canary', label: 'Canary' }, + { value: 'Atlantic/Cape_Verde', label: 'Cape Verde' }, + { value: 'Atlantic/Faroe', label: 'Faroe' }, + { value: 'Atlantic/Madeira', label: 'Madeira' }, + { value: 'Atlantic/Reykjavik', label: 'Reykjavik' }, + { value: 'Atlantic/South_Georgia', label: 'South Georgia' }, + { value: 'Atlantic/Stanley', label: 'Stanley' }, + { value: 'Atlantic/St_Helena', label: 'St Helena' }, + ], + }, + { + label: 'Australia', + options: [ + { value: 'Australia/Adelaide', label: 'Adelaide' }, + { value: 'Australia/Brisbane', label: 'Brisbane' }, + { value: 'Australia/Broken_Hill', label: 'Broken Hill' }, + { value: 'Australia/Currie', label: 'Currie' }, + { value: 'Australia/Darwin', label: 'Darwin' }, + { value: 'Australia/Eucla', label: 'Eucla' }, + { value: 'Australia/Hobart', label: 'Hobart' }, + { value: 'Australia/Lindeman', label: 'Lindeman' }, + { value: 'Australia/Lord_Howe', label: 'Lord Howe' }, + { value: 'Australia/Melbourne', label: 'Melbourne' }, + { value: 'Australia/Perth', label: 'Perth' }, + { value: 'Australia/Sydney', label: 'Sydney' }, + ], + }, + { + label: 'Europe', + options: [ + { value: 'Europe/Amsterdam', label: 'Amsterdam' }, + { value: 'Europe/Andorra', label: 'Andorra' }, + { value: 'Europe/Astrakhan', label: 'Astrakhan' }, + { value: 'Europe/Athens', label: 'Athens' }, + { value: 'Europe/Belgrade', label: 'Belgrade' }, + { value: 'Europe/Berlin', label: 'Berlin' }, + { value: 'Europe/Bratislava', label: 'Bratislava' }, + { value: 'Europe/Brussels', label: 'Brussels' }, + { value: 'Europe/Bucharest', label: 'Bucharest' }, + { value: 'Europe/Budapest', label: 'Budapest' }, + { value: 'Europe/Busingen', label: 'Busingen' }, + { value: 'Europe/Chisinau', label: 'Chisinau' }, + { value: 'Europe/Copenhagen', label: 'Copenhagen' }, + { value: 'Europe/Dublin', label: 'Dublin' }, + { value: 'Europe/Gibraltar', label: 'Gibraltar' }, + { value: 'Europe/Guernsey', label: 'Guernsey' }, + { value: 'Europe/Helsinki', label: 'Helsinki' }, + { value: 'Europe/Isle_of_Man', label: 'Isle of Man' }, + { value: 'Europe/Istanbul', label: 'Istanbul' }, + { value: 'Europe/Jersey', label: 'Jersey' }, + { value: 'Europe/Kaliningrad', label: 'Kaliningrad' }, + { value: 'Europe/Kiev', label: 'Kiev' }, + { value: 'Europe/Kirov', label: 'Kirov' }, + { value: 'Europe/Lisbon', label: 'Lisbon' }, + { value: 'Europe/Ljubljana', label: 'Ljubljana' }, + { value: 'Europe/London', label: 'London' }, + { value: 'Europe/Luxembourg', label: 'Luxembourg' }, + { value: 'Europe/Madrid', label: 'Madrid' }, + { value: 'Europe/Malta', label: 'Malta' }, + { value: 'Europe/Mariehamn', label: 'Mariehamn' }, + { value: 'Europe/Minsk', label: 'Minsk' }, + { value: 'Europe/Monaco', label: 'Monaco' }, + { value: 'Europe/Moscow', label: 'Moscow' }, + { value: 'Europe/Oslo', label: 'Oslo' }, + { value: 'Europe/Paris', label: 'Paris' }, + { value: 'Europe/Podgorica', label: 'Podgorica' }, + { value: 'Europe/Prague', label: 'Prague' }, + { value: 'Europe/Riga', label: 'Riga' }, + { value: 'Europe/Rome', label: 'Rome' }, + { value: 'Europe/Samara', label: 'Samara' }, + { value: 'Europe/San_Marino', label: 'San Marino' }, + { value: 'Europe/Sarajevo', label: 'Sarajevo' }, + { value: 'Europe/Saratov', label: 'Saratov' }, + { value: 'Europe/Simferopol', label: 'Simferopol' }, + { value: 'Europe/Skopje', label: 'Skopje' }, + { value: 'Europe/Sofia', label: 'Sofia' }, + { value: 'Europe/Stockholm', label: 'Stockholm' }, + { value: 'Europe/Tallinn', label: 'Tallinn' }, + { value: 'Europe/Tirane', label: 'Tirane' }, + { value: 'Europe/Ulyanovsk', label: 'Ulyanovsk' }, + { value: 'Europe/Uzhgorod', label: 'Uzhgorod' }, + { value: 'Europe/Vaduz', label: 'Vaduz' }, + { value: 'Europe/Vatican', label: 'Vatican' }, + { value: 'Europe/Vienna', label: 'Vienna' }, + { value: 'Europe/Vilnius', label: 'Vilnius' }, + { value: 'Europe/Volgograd', label: 'Volgograd' }, + { value: 'Europe/Warsaw', label: 'Warsaw' }, + { value: 'Europe/Zagreb', label: 'Zagreb' }, + { value: 'Europe/Zaporozhye', label: 'Zaporozhye' }, + { value: 'Europe/Zurich', label: 'Zurich' }, + ], + }, + { + label: 'Indian', + options: [ + { value: 'Indian/Antananarivo', label: 'Antananarivo' }, + { value: 'Indian/Chagos', label: 'Chagos' }, + { value: 'Indian/Christmas', label: 'Christmas' }, + { value: 'Indian/Cocos', label: 'Cocos' }, + { value: 'Indian/Comoro', label: 'Comoro' }, + { value: 'Indian/Kerguelen', label: 'Kerguelen' }, + { value: 'Indian/Mahe', label: 'Mahe' }, + { value: 'Indian/Maldives', label: 'Maldives' }, + { value: 'Indian/Mauritius', label: 'Mauritius' }, + { value: 'Indian/Mayotte', label: 'Mayotte' }, + { value: 'Indian/Reunion', label: 'Reunion' }, + ], + }, + { + label: 'Pacific', + options: [ + { value: 'Pacific/Apia', label: 'Apia' }, + { value: 'Pacific/Auckland', label: 'Auckland' }, + { value: 'Pacific/Bougainville', label: 'Bougainville' }, + { value: 'Pacific/Chatham', label: 'Chatham' }, + { value: 'Pacific/Chuuk', label: 'Chuuk' }, + { value: 'Pacific/Easter', label: 'Easter' }, + { value: 'Pacific/Efate', label: 'Efate' }, + { value: 'Pacific/Enderbury', label: 'Enderbury' }, + { value: 'Pacific/Fakaofo', label: 'Fakaofo' }, + { value: 'Pacific/Fiji', label: 'Fiji' }, + { value: 'Pacific/Funafuti', label: 'Funafuti' }, + + { value: 'Pacific/Galapagos', label: 'Galapagos' }, + { value: 'Pacific/Gambier', label: 'Gambier' }, + { value: 'Pacific/Guadalcanal', label: 'Guadalcanal' }, + { value: 'Pacific/Guam', label: 'Guam' }, + { value: 'Pacific/Honolulu', label: 'Honolulu' }, + { value: 'Pacific/Kiritimati', label: 'Kiritimati' }, + { value: 'Pacific/Kosrae', label: 'Kosrae' }, + { value: 'Pacific/Kwajalein', label: 'Kwajalein' }, + { value: 'Pacific/Majuro', label: 'Majuro' }, + { value: 'Pacific/Marquesas', label: 'Marquesas' }, + { value: 'Pacific/Midway', label: 'Midway' }, + { value: 'Pacific/Nauru', label: 'Nauru' }, + { value: 'Pacific/Niue', label: 'Niue' }, + { value: 'Pacific/Norfolk', label: 'Norfolk' }, + { value: 'Pacific/Noumea', label: 'Noumea' }, + { value: 'Pacific/Pago_Pago', label: 'Pago Pago' }, + { value: 'Pacific/Palau', label: 'Palau' }, + { value: 'Pacific/Pitcairn', label: 'Pitcairn' }, + { value: 'Pacific/Pohnpei', label: 'Pohnpei' }, + { value: 'Pacific/Port_Moresby', label: 'Port Moresby' }, + { value: 'Pacific/Rarotonga', label: 'Rarotonga' }, + { value: 'Pacific/Saipan', label: 'Saipan' }, + { value: 'Pacific/Tahiti', label: 'Tahiti' }, + { value: 'Pacific/Tarawa', label: 'Tarawa' }, + { value: 'Pacific/Tongatapu', label: 'Tongatapu' }, + { value: 'Pacific/Wake', label: 'Wake' }, + { value: 'Pacific/Wallis', label: 'Wallis' }, + ], + }, + + { + label: 'UTC', + options: [{ value: 'UTC', label: 'UTC' }], + }, +]; +export const DEFAULT_TIMEZONE = 'UTC+0'; diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 2319f1a6..9ea391c7 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -109,16 +109,19 @@ export interface UserInfoBase { */ status?: string; /** roles */ - is_admin?: true; + is_admin?: boolean; } export interface UserInfoRes extends UserInfoBase { bio: string; bio_html: string; create_time?: string; - /** value = 1 active; value = 2 inactivated + /** + * value = 1 active; + * value = 2 inactivated */ mail_status: number; + language: string; e_mail?: string; [prop: string]: any; } @@ -228,6 +231,7 @@ export type AdminContentsFilterBy = 'normal' | 'closed' | 'deleted'; export interface AdminContentsReq extends Paging { status: AdminContentsFilterBy; + query?: string; } /** @@ -257,12 +261,15 @@ export interface AdminSettingsGeneral { name: string; short_description: string; description: string; + site_url: string; + contact_email: string; } export interface AdminSettingsInterface { logo: string; language: string; theme: string; + time_zone?: string; } export interface AdminSettingsSmtp { @@ -321,3 +328,24 @@ export interface SearchResItem { export interface SearchRes extends ListResult { 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; + https: boolean; + version_info: { + remote_version: string; + version: string; + }; + }; +} diff --git a/ui/src/components/AccordionNav/index.tsx b/ui/src/components/AccordionNav/index.tsx index ccc14d44..b3403bc8 100644 --- a/ui/src/components/AccordionNav/index.tsx +++ b/ui/src/components/AccordionNav/index.tsx @@ -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' }); diff --git a/ui/src/components/Actions/index.tsx b/ui/src/components/Actions/index.tsx index 87c35cef..d9395723 100644 --- a/ui/src/components/Actions/index.tsx +++ b/ui/src/components/Actions/index.tsx @@ -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 = ({ 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 = ({ className, data }) => { }, []); const handleVote = (type: 'up' | 'down') => { - if (!isLogin(true)) { + if (!tryNormalLogged(true)) { return; } @@ -84,7 +84,7 @@ const Index: FC = ({ className, data }) => { }; const handleBookmark = () => { - if (!isLogin(true)) { + if (!tryNormalLogged(true)) { return; } bookmark({ diff --git a/ui/src/components/BaseUserCard/index.tsx b/ui/src/components/BaseUserCard/index.tsx index fa524bda..d170d3f8 100644 --- a/ui/src/components/BaseUserCard/index.tsx +++ b/ui/src/components/BaseUserCard/index.tsx @@ -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 { diff --git a/ui/src/components/Comment/components/ActionBar/index.tsx b/ui/src/components/Comment/components/ActionBar/index.tsx index a801a6bb..a13bff1a 100644 --- a/ui/src/components/Comment/components/ActionBar/index.tsx +++ b/ui/src/components/Comment/components/ActionBar/index.tsx @@ -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, diff --git a/ui/src/components/Comment/components/Form/index.tsx b/ui/src/components/Comment/components/Form/index.tsx index 4971c4ef..70c62fdd 100644 --- a/ui/src/components/Comment/components/Form/index.tsx +++ b/ui/src/components/Comment/components/Form/index.tsx @@ -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 = '', diff --git a/ui/src/components/Comment/components/Reply/index.tsx b/ui/src/components/Comment/components/Reply/index.tsx index 931a8909..01c9399c 100644 --- a/ui/src/components/Comment/components/Reply/index.tsx +++ b/ui/src/components/Comment/components/Reply/index.tsx @@ -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(''); diff --git a/ui/src/components/Comment/index.tsx b/ui/src/components/Comment/index.tsx index e7fd3bff..1f57e737 100644 --- a/ui/src/components/Comment/index.tsx +++ b/ui/src/components/Comment/index.tsx @@ -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') { diff --git a/ui/src/components/Editor/ToolBars/image.tsx b/ui/src/components/Editor/ToolBars/image.tsx index 5f4475a2..bfa4c530 100644 --- a/ui/src/components/Editor/ToolBars/image.tsx +++ b/ui/src/components/Editor/ToolBars/image.tsx @@ -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 = ({ editor }) => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); diff --git a/ui/src/components/FollowingTags/index.tsx b/ui/src/components/FollowingTags/index.tsx index 7f93a205..9e3156b0 100644 --- a/ui/src/components/FollowingTags/index.tsx +++ b/ui/src/components/FollowingTags/index.tsx @@ -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 { tryLoggedAndActicevated } 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 (!tryLoggedAndActicevated().ok) { return null; } diff --git a/ui/src/components/FormatTime/index.tsx b/ui/src/components/FormatTime/index.tsx index 45649087..d3a4d024 100644 --- a/ui/src/components/FormatTime/index.tsx +++ b/ui/src/components/FormatTime/index.tsx @@ -37,10 +37,10 @@ const Index: FC = ({ time, preFix, className }) => { between < 3600 * 24 * 366 && dayjs.unix(from).format('YYYY') === dayjs.unix(now).format('YYYY') ) { - return dayjs.unix(from).format(t('dates.long_date')); + return dayjs.unix(from).tz().format(t('dates.long_date')); } - return dayjs.unix(from).format(t('dates.long_date_with_year')); + return dayjs.unix(from).tz().format(t('dates.long_date_with_year')); }; if (!time) { @@ -50,8 +50,8 @@ const Index: FC = ({ time, preFix, className }) => { return ( diff --git a/ui/src/components/Header/components/NavItems/index.tsx b/ui/src/components/Header/components/NavItems/index.tsx index 51711950..17e1bc64 100644 --- a/ui/src/components/Header/components/NavItems/index.tsx +++ b/ui/src/components/Header/components/NavItems/index.tsx @@ -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; diff --git a/ui/src/components/Header/index.scss b/ui/src/components/Header/index.scss index 1d664db0..5775e32a 100644 --- a/ui/src/components/Header/index.scss +++ b/ui/src/components/Header/index.scss @@ -50,6 +50,10 @@ @media (max-width: 992.9px) { #header { + .logo { + max-width: 93px; + max-height: auto; + } .nav-grow { flex-grow: 1!important; } diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index abbd0c3c..8681c94c 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -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(() => { diff --git a/ui/src/components/HotQuestions/index.tsx b/ui/src/components/HotQuestions/index.tsx index d398d172..da0305d0 100644 --- a/ui/src/components/HotQuestions/index.tsx +++ b/ui/src/components/HotQuestions/index.tsx @@ -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' }); diff --git a/ui/src/components/Mentions/index.tsx b/ui/src/components/Mentions/index.tsx index 9b29c86d..23cfa083 100644 --- a/ui/src/components/Mentions/index.tsx +++ b/ui/src/components/Mentions/index.tsx @@ -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; diff --git a/ui/src/components/Modal/PicAuthCodeModal.tsx b/ui/src/components/Modal/PicAuthCodeModal.tsx index f0fe2091..ec194a64 100644 --- a/ui/src/components/Modal/PicAuthCodeModal.tsx +++ b/ui/src/components/Modal/PicAuthCodeModal.tsx @@ -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 = ({ 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, diff --git a/ui/src/components/Operate/index.tsx b/ui/src/components/Operate/index.tsx index 6d76af2a..cdacc751 100644 --- a/ui/src/components/Operate/index.tsx +++ b/ui/src/components/Operate/index.tsx @@ -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 = ({ }; const handleAction = (action) => { - if (!isLogin(true)) { + if (!tryNormalLogged(true)) { return; } if (action === 'delete') { diff --git a/ui/src/components/PageTitle/index.tsx b/ui/src/components/PageTitle/index.tsx index a11ea992..a581af2e 100644 --- a/ui/src/components/PageTitle/index.tsx +++ b/ui/src/components/PageTitle/index.tsx @@ -18,7 +18,7 @@ const PageTitle: FC = ({ title = '', suffix = '' }) => { if (!suffix) { suffix = `${siteInfo.name}`; } - title = title ? `${title} - ${suffix}` : suffix; + title = title ? `${title}${suffix ? ` - ${suffix}` : ''}` : suffix; return <>{setPageTitle(title)}; }; diff --git a/ui/src/components/QueryGroup/index.tsx b/ui/src/components/QueryGroup/index.tsx index fae01621..f69e3596 100644 --- a/ui/src/components/QueryGroup/index.tsx +++ b/ui/src/components/QueryGroup/index.tsx @@ -1,6 +1,6 @@ import { FC, memo } from 'react'; import { ButtonGroup, Button, DropdownButton, Dropdown } from 'react-bootstrap'; -import { useSearchParams } from 'react-router-dom'; +import { useSearchParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; @@ -11,6 +11,8 @@ interface Props { currentSort: string; sortKey?: string; className?: string; + pathname?: string; + wrapClassName?: string; } const MAX_BUTTON_COUNT = 3; const Index: FC = ({ @@ -19,8 +21,11 @@ const Index: FC = ({ sortKey = 'order', i18nKeyPrefix = '', className = '', + pathname = '', + wrapClassName = '', }) => { const [searchParams, setUrlSearchParams] = useSearchParams(); + const navigate = useNavigate(); const { t } = useTranslation('translation', { keyPrefix: i18nKeyPrefix, @@ -36,7 +41,11 @@ const Index: FC = ({ const handleClick = (e, type) => { e.preventDefault(); const str = handleParams(type); - setUrlSearchParams(str); + if (pathname) { + navigate(`${pathname}${str}`); + } else { + setUrlSearchParams(str); + } }; const filteredData = data.filter((_, index) => index > MAX_BUTTON_COUNT - 2); @@ -44,7 +53,7 @@ const Index: FC = ({ return (typeof btn === 'string' ? btn : btn.name) === currentSort; }); return ( - + {data.map((btn, index) => { const key = typeof btn === 'string' ? btn : btn.sort; const name = typeof btn === 'string' ? btn : btn.name; @@ -55,7 +64,7 @@ const Index: FC = ({ variant="outline-secondary" active={currentSort === name} className={classNames( - 'text-capitalize', + 'text-capitalize fit-content', data.length > MAX_BUTTON_COUNT && index > MAX_BUTTON_COUNT - 2 && 'd-none d-md-block', @@ -69,7 +78,9 @@ const Index: FC = ({ } : {} } - href={handleParams(key)} + href={ + pathname ? `${pathname}${handleParams(key)}` : handleParams(key) + } onClick={(evt) => handleClick(evt, key)}> {t(name)} @@ -95,7 +106,11 @@ const Index: FC = ({ 'd-block d-md-none', className, )} - href={handleParams(key)} + href={ + pathname + ? `${pathname}${handleParams(key)}` + : handleParams(key) + } onClick={(evt) => handleClick(evt, key)}> {t(name)} diff --git a/ui/src/components/QuestionList/index.tsx b/ui/src/components/QuestionList/index.tsx index c513f795..a45cd401 100644 --- a/ui/src/components/QuestionList/index.tsx +++ b/ui/src/components/QuestionList/index.tsx @@ -1,10 +1,9 @@ import { FC } from 'react'; -import { Row, Col, ListGroup } from 'react-bootstrap'; +import { 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', @@ -103,22 +103,19 @@ const QuestionList: FC = ({ source }) => { return (
- - -
- {source === 'questions' - ? t('all_questions') - : t('x_questions', { count })} -
- - - - -
+
+
+ {source === 'questions' + ? t('all_questions') + : t('x_questions', { count })} +
+ +
{listData?.list?.map((li) => { return ( diff --git a/ui/src/components/Share/index.tsx b/ui/src/components/Share/index.tsx index d5866d98..652dcef1 100644 --- a/ui/src/components/Share/index.tsx +++ b/ui/src/components/Share/index.tsx @@ -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 = ({ 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); diff --git a/ui/src/components/TagSelector/index.tsx b/ui/src/components/TagSelector/index.tsx index 790b8242..8747f65a 100644 --- a/ui/src/components/TagSelector/index.tsx +++ b/ui/src/components/TagSelector/index.tsx @@ -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'; diff --git a/ui/src/components/Unactivate/index.tsx b/ui/src/components/Unactivate/index.tsx index 5ff85c2a..420891cc 100644 --- a/ui/src/components/Unactivate/index.tsx +++ b/ui/src/components/Unactivate/index.tsx @@ -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 = ({ 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({ captcha_code: { value: '', @@ -48,7 +46,7 @@ const Index: React.FC = ({ 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, diff --git a/ui/src/components/UserCard/index.tsx b/ui/src/components/UserCard/index.tsx index 9688f818..169354ec 100644 --- a/ui/src/components/UserCard/index.tsx +++ b/ui/src/components/UserCard/index.tsx @@ -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 { @@ -34,12 +33,21 @@ const Index: FC = ({ data, time, preFix, className = '' }) => { /> ) : ( - + <> + + + + )}
diff --git a/ui/src/hooks/useChangeModal/index.tsx b/ui/src/hooks/useChangeModal/index.tsx index b46aa80d..529f7a3a 100644 --- a/ui/src/hooks/useChangeModal/index.tsx +++ b/ui/src/hooks/useChangeModal/index.tsx @@ -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); diff --git a/ui/src/hooks/usePageUsers/index.tsx b/ui/src/hooks/usePageUsers/index.tsx index 72203619..2c828695 100644 --- a/ui/src/hooks/usePageUsers/index.tsx +++ b/ui/src/hooks/usePageUsers/index.tsx @@ -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 = () => { diff --git a/ui/src/hooks/useReportModal/index.tsx b/ui/src/hooks/useReportModal/index.tsx index 2566ccd6..28b85eb5 100644 --- a/ui/src/hooks/useReportModal/index.tsx +++ b/ui/src/hooks/useReportModal/index.tsx @@ -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; diff --git a/ui/src/i18n/init.ts b/ui/src/i18n/init.ts index 495ecc1d..ec4bf378 100644 --- a/ui/src/i18n/init.ts +++ b/ui/src/i18n/init.ts @@ -2,9 +2,10 @@ import { initReactI18next } from 'react-i18next'; import i18next from 'i18next'; import Backend from 'i18next-http-backend'; +import en_US from '@i18n/en_US.yaml'; +import zh_CN from '@i18n/zh_CN.yaml'; -import en from './locales/en.json'; -import zh from './locales/zh_CN.json'; +import { DEFAULT_LANG } from '@/common/constants'; i18next // load translation using http @@ -14,19 +15,20 @@ i18next .init({ resources: { en_US: { - translation: en, + translation: en_US.ui, }, zh_CN: { - translation: zh, + translation: zh_CN.ui, }, }, // 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, }, react: { - transSupportBasicHtmlNodes: true, // allow
and simple html elements in translations + transSupportBasicHtmlNodes: true, + // allow
and simple html elements in translations transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'], }, // backend: { diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json deleted file mode 100644 index 3a143144..00000000 --- a/ui/src/i18n/locales/en.json +++ /dev/null @@ -1,934 +0,0 @@ -{ - "how_to_format": { - "title": "How to Format", - "description": "
  • to make links

    <https://url.com>

    [Title](https://url.com)
  • put returns between paragraphs

  • _italic_ or **bold**

  • indent code by 4 spaces

  • quote by placing > at start of line

  • backtick escapes `like _this_`

  • create code fences with backticks `

    ```
    code here
    ```
" - }, - "pagination": { - "prev": "Prev", - "next": "Next" - }, - "page_title": { - "question": "Question", - "questions": "Questions", - "tag": "Tag", - "tags": "Tags", - "tag_wiki": "tag wiki", - "edit_tag": "Edit Tag", - "ask_a_question": "Add Question", - "edit_question": "Edit Question", - "edit_answer": "Edit Answer", - "search": "Search", - "posts_containing": "Posts containing", - "settings": "Settings", - "notifications": "Notifications", - "login": "Log In", - "sign_up": "Sign Up", - "account_recovery": "Account Recovery", - "account_activation": "Account Activation", - "confirm_email": "Confirm Email", - "account_suspended": "Account Suspended", - "admin": "Admin", - "change_email": "Modify Email" - }, - "notifications": { - "title": "Notifications", - "inbox": "Inbox", - "achievement": "Achievements", - "all_read": "Mark all as read", - "show_more": "Show more" - }, - "suspended": { - "title": "Your Account has been Suspended", - "until_time": "Your account was suspended until {{ time }}.", - "forever": "This user was suspended forever.", - "end": "You don't meet a community guideline." - }, - "editor": { - "blockquote": { - "text": "Blockquote" - }, - "bold": { - "text": "Strong" - }, - "chart": { - "text": "Chart", - "flow_chart": "Flow chart", - "sequence_diagram": "Sequence diagram", - "class_diagram": "Class diagram", - "state_diagram": "State diagram", - "entity_relationship_diagram": "Entity relationship diagram", - "user_defined_diagram": "User defined diagram", - "gantt_chart": "Gantt chart", - "pie_chart": "Pie chart" - }, - "code": { - "text": "Code Sample", - "add_code": "Add code sample", - "form": { - "fields": { - "code": { - "label": "Code", - "msg": { - "empty": "Code cannot be empty." - } - }, - "language": { - "label": "Language (optional)", - "placeholder": "Automatic detection" - } - } - }, - "btn_cancel": "Cancel", - "btn_confirm": "Add" - }, - "formula": { - "text": "Formula", - "options": { - "inline": "Inline formula", - "block": "Block formula" - } - }, - "heading": { - "text": "Heading", - "options": { - "h1": "Heading 1", - "h2": "Heading 2", - "h3": "Heading 3", - "h4": "Heading 4", - "h5": "Heading 5", - "h6": "Heading 6" - } - }, - "help": { - "text": "Help" - }, - "hr": { - "text": "Horizontal Rule" - }, - "image": { - "text": "Image", - "add_image": "Add image", - "tab_image": "Upload image", - "form_image": { - "fields": { - "file": { - "label": "Image File", - "btn": "Select image", - "msg": { - "empty": "File cannot be empty.", - "only_image": "Only image files are allowed.", - "max_size": "File size cannot exceed 4MB." - } - }, - "description": { - "label": "Description (optional)" - } - } - }, - "tab_url": "Image URL", - "form_url": { - "fields": { - "url": { - "label": "Image URL", - "msg": { - "empty": "Image URL cannot be empty." - } - }, - "name": { - "label": "Description (optional)" - } - } - }, - "btn_cancel": "Cancel", - "btn_confirm": "Add", - "uploading": "Uploading" - }, - "indent": { - "text": "Indent" - }, - "outdent": { - "text": "Outdent" - }, - "italic": { - "text": "Emphasis" - }, - "link": { - "text": "Hyperlink", - "add_link": "Add hyperlink", - "form": { - "fields": { - "url": { - "label": "URL", - "msg": { - "empty": "URL cannot be empty." - } - }, - "name": { - "label": "Description (optional)" - } - } - }, - "btn_cancel": "Cancel", - "btn_confirm": "Add" - }, - "ordered_list": { - "text": "Numbered List" - }, - "unordered_list": { - "text": "Bulleted List" - }, - "table": { - "text": "Table", - "heading": "Heading", - "cell": "Cell" - } - }, - "close_modal": { - "title": "I am closing this post as...", - "btn_cancel": "Cancel", - "btn_submit": "Submit", - "remark": { - "empty": "Cannot be empty." - }, - "msg": { - "empty": "Please select a reason." - } - }, - "report_modal": { - "flag_title": "I am flagging to report this post as...", - "close_title": "I am closing this post as...", - "review_question_title": "Review question", - "review_answer_title": "Review answer", - "review_comment_title": "Review comment", - "btn_cancel": "Cancel", - "btn_submit": "Submit", - "remark": { - "empty": "Cannot be empty." - }, - "msg": { - "empty": "Please select a reason." - } - }, - "tag_modal": { - "title": "Create new tag", - "form": { - "fields": { - "display_name": { - "label": "Display Name", - "msg": { - "empty": "Display name cannot be empty.", - "range": "Display name up to 35 characters." - } - }, - "slug_name": { - "label": "URL Slug", - "description": "Must use the character set \"a-z\", \"0-9\", \"+ # - .\"", - "msg": { - "empty": "URL slug cannot be empty.", - "range": "URL slug up to 35 characters.", - "character": "URL slug contains unallowed character set." - } - }, - "description": { - "label": "Description (optional)" - } - } - }, - "btn_cancel": "Cancel", - "btn_submit": "Submit" - }, - "tag_info": { - "created_at": "Created", - "edited_at": "Edited", - "synonyms": { - "title": "Synonyms", - "text": "The following tags will be remapped to", - "empty": "No synonyms found.", - "btn_add": "Add a synonym", - "btn_edit": "Edit", - "btn_save": "Save" - }, - "synonyms_text": "The following tags will be remapped to", - "delete": { - "title": "Delete this tag", - "content": "

We do not allowed deleting tag with posts.

Please remove this tag from the posts first.

", - "content2": "Are you sure you wish to delete?", - "close": "Close" - } - }, - "edit_tag": { - "title": "Edit Tag", - "default_reason": "Edit tag", - "form": { - "fields": { - "revision": { - "label": "Revision" - }, - "display_name": { - "label": "Display Name" - }, - "slug_name": { - "label": "URL Slug", - "info": "Must use the character set \"a-z\", \"0-9\", \"+ # - .\"" - }, - "description": { - "label": "Description" - }, - "edit_summary": { - "label": "Edit Summary", - "placeholder": "Briefly explain your changes (corrected spelling, fixed grammar, improved formatting)" - } - } - }, - "btn_save_edits": "Save edits", - "btn_cancel": "Cancel" - }, - "dates": { - "long_date": "MMM D", - "long_date_with_year": "MMM D, YYYY", - "long_date_with_time": "MMM D, YYYY [at] HH:mm", - "now": "now", - "x_seconds_ago": "{{count}}s ago", - "x_minutes_ago": "{{count}}m ago", - "x_hours_ago": "{{count}}h ago" - }, - "comment": { - "btn_add_comment": "Add comment", - "reply_to": "Reply to", - "btn_reply": "Reply", - "btn_edit": "Edit", - "btn_delete": "Delete", - "btn_flag": "Flag", - "btn_save_edits": "Save edits", - "btn_cancel": "Cancel", - "show_more": "Show more comment", - "tip_question": "Use comments to ask for more information or suggest improvements. Avoid answering questions in comments.", - "tip_answer": "Use comments to reply to other users or notify them of changes. If you are adding new information, edit your post instead of commenting." - }, - "edit_answer": { - "title": "Edit Answer", - "default_reason": "Edit answer", - "form": { - "fields": { - "revision": { - "label": "Revision" - }, - "answer": { - "label": "Answer" - }, - "edit_summary": { - "label": "Edit Summary", - "placeholder": "Briefly explain your changes (corrected spelling, fixed grammar, improved formatting)" - } - } - }, - "btn_save_edits": "Save edits", - "btn_cancel": "Cancel" - }, - "tags": { - "title": "Tags", - "sort_buttons": { - "popular": "Popular", - "name": "Name", - "newest": "newest" - }, - "button_follow": "Follow", - "button_following": "Following", - "tag_label": "questions", - "search_placeholder": "Filter by tag name", - "no_description": "The tag has no description.", - "more": "More" - }, - "ask": { - "title": "Add Question", - "edit_title": "Edit Question", - "default_reason": "Edit question", - "similar_questions": "Similar questions", - "form": { - "fields": { - "revision": { - "label": "Revision" - }, - "title": { - "label": "Title", - "placeholder": "Be specific and imagine you're asking a question to another person", - "msg": { - "empty": "Title cannot be empty.", - "range": "Title up to 150 characters" - } - }, - "body": { - "label": "Body", - "msg": { - "empty": "Body cannot be empty." - } - }, - "tags": { - "label": "Tags", - "msg": { - "empty": "Tags cannot be empty." - } - }, - "answer": { - "label": "Answer", - "msg": { - "empty": "Answer cannot be empty." - } - } - } - }, - "btn_post_question": "Post your question", - "btn_save_edits": "Save edits", - "answer_question": "Answer your own question", - "post_question&answer": "Post your question and answer" - }, - "tag_selector": { - "add_btn": "Add tag", - "create_btn": "Create new tag", - "search_tag": "Search tag", - "hint": "Describe what your question is about, at least one tag is required.", - "no_result": "No tags matched" - }, - "header": { - "nav": { - "question": "Questions", - "tag": "Tags", - "user": "Users", - "profile": "Profile", - "setting": "Settings", - "logout": "Log out", - "admin": "Admin" - }, - "search": { - "placeholder": "Search" - } - }, - "footer": { - "build_on": "Built on <1> Answer - the open-source software that powers Q&A communities
Made with love © 2022 Answer" - }, - "upload_img": { - "name": "Change", - "loading": "loading..." - }, - "pic_auth_code": { - "title": "Captcha", - "placeholder": "Type the text above", - "msg": { - "empty": "Captcha cannot be empty." - } - }, - "inactive": { - "first": "You're almost done! We sent an activation mail to {{mail}}. Please follow the instructions in the mail to activate your account.", - "info": "If it doesn't arrive, check your spam folder.", - "another": "We sent another activation email to you at {{mail}}. It might take a few minutes for it to arrive; be sure to check your spam folder.", - "btn_name": "Resend activation email", - "change_btn_name": "Change email", - "msg": { - "empty": "Cannot be empty." - } - }, - "login": { - "page_title": "Welcome to Answer", - "info_sign": "Don't have an account? <1>Sign up", - "info_login": "Already have an account? <1>Log in", - "forgot_pass": "Forgot password?", - "name": { - "label": "Name", - "msg": { - "empty": "Name cannot be empty.", - "range": "Name up to 30 characters." - } - }, - "email": { - "label": "Email", - "msg": { - "empty": "Email cannot be empty." - } - }, - "password": { - "label": "Password", - "msg": { - "empty": "Password cannot be empty.", - "different": "The passwords entered on both sides are inconsistent" - } - } - }, - "account_forgot": { - "page_title": "Forgot Your Password", - "btn_name": "Send me recovery email", - "send_success": "If an account matches {{mail}}, you should receive an email with instructions on how to reset your password shortly.", - "email": { - "label": "Email", - "msg": { - "empty": "Email cannot be empty." - } - } - }, - "change_email": { - "page_title": "Welcome to Answer", - "btn_cancel": "Cancel", - "btn_update": "Update email address", - "send_success": "If an account matches {{mail}}, you should receive an email with instructions on how to reset your password shortly.", - "email": { - "label": "New Email", - "msg": { - "empty": "Email cannot be empty." - } - } - }, - "password_reset": { - "page_title": "Password Reset", - "btn_name": "Reset my password", - "reset_success": "You successfully changed your password; you will be redirected to the log in page.", - "link_invalid": "Sorry, this password reset link is no longer valid. Perhaps your password is already reset?", - "to_login": "Continue to log in page", - "password": { - "label": "Password", - "msg": { - "empty": "Password cannot be empty.", - "length": "The length needs to be between 8 and 32", - "different": "The passwords entered on both sides are inconsistent" - } - }, - "password_confirm": { - "label": "Confirm New Password" - } - }, - "settings": { - "page_title": "Settings", - "nav": { - "profile": "Profile", - "notification": "Notifications", - "account": "Account", - "interface": "Interface" - }, - "profile": { - "btn_name": "Update profile", - "display_name": { - "label": "Display Name", - "msg": "Display name cannot be empty.", - "msg_range": "Display name up to 30 characters" - }, - "username": { - "label": "Username", - "caption": "People can mention you as \"@username\".", - "msg": "Username cannot be empty.", - "msg_range": "Username up to 30 characters", - "character": "Must use the character set \"a-z\", \"0-9\", \" - . _\"" - }, - "avatar": { - "label": "Profile Image", - "gravatar": "Gravatar", - "gravatar_text": "You can change image on <1>gravatar.com", - "custom": "Custom", - "btn_refresh": "Refresh", - "custom_text": "You can upload your image.", - "default": "Default", - "msg": "Please upload an avatar" - }, - "bio": { - "label": "About Me (optional)" - }, - "website": { - "label": "Website (optional)", - "placeholder": "https://example.com", - "msg": "Website incorrect format" - }, - "location": { - "label": "Location (optional)", - "placeholder": "City, Country" - } - }, - "notification": { - "email": { - "label": "Email Notifications", - "radio": "Answers to your questions, comments, and more" - } - }, - "account": { - "change_email_btn": "Change email", - "change_pass_btn": "Change password", - "change_email_info": "We've sent an email to that address. Please follow the confirmation instructions.", - "email": { - "label": "Email", - "msg": "Email cannot be empty." - }, - "password_title": "Password", - "current_pass": { - "label": "Current Password", - "msg": { - "empty": "Current Password cannot be empty.", - "length": "The length needs to be between 8 and 32.", - "different": "The two entered passwords do not match." - } - }, - "new_pass": { - "label": "New Password" - }, - "pass_confirm": { - "label": "Confirm New Password" - } - }, - "interface": { - "lang": { - "label": "Interface Language", - "text": "User interface language. It will change when you refresh the page." - } - } - }, - "toast": { - "update": "update success", - "update_password": "Password changed successfully.", - "flag_success": "Thanks for flagging." - }, - "related_question": { - "title": "Related Questions", - "btn": "Add question", - "answers": "answers" - }, - "question_detail": { - "Asked": "Asked", - "asked": "asked", - "update": "Modified", - "edit": "edited", - "Views": "Viewed", - "Follow": "Follow", - "Following": "Following", - "answered": "answered", - "closed_in": "Closed in", - "show_exist": "Show existing question.", - "answers": { - "title": "Answers", - "score": "Score", - "newest": "Newest", - "btn_accept": "Accept", - "btn_accepted": "Accepted" - }, - "write_answer": { - "title": "Your Answer", - "btn_name": "Post your answer", - "confirm_title": "Continue to answer", - "continue": "Continue", - "confirm_info": "

Are you sure you want to add another answer?

You could use the edit link to refine and improve your existing answer, instead.

", - "empty": "Answer cannot be empty." - } - }, - "delete": { - "title": "Delete this post", - "question": "We do not recommend deleting questions with answers because doing so deprives future readers of this knowledge.

Repeated deletion of answered questions can result in your account being blocked from asking. Are you sure you wish to delete?", - "answer_accepted": "

We do not recommend deleting accepted answer because doing so deprives future readers of this knowledge.

Repeated deletion of accepted answers can result in your account being blocked from answering. Are you sure you wish to delete?", - "other": "Are you sure you wish to delete?", - "tip_question_deleted": "This post has been deleted", - "tip_answer_deleted": "This answer has been deleted" - }, - "btns": { - "confirm": "Confirm", - "cancel": "Cancel", - "save": "Save", - "delete": "Delete", - "login": "Log in", - "signup": "Sign up", - "logout": "Log out", - "verify": "Verify", - "add_question": "Add question" - }, - "search": { - "title": "Search Results", - "keywords": "Keywords", - "options": "Options", - "follow": "Follow", - "following": "Following", - "counts": "{{count}} Results", - "more": "More", - "sort_btns": { - "relevance": "Relevance", - "newest": "Newest", - "active": "Active", - "score": "Score" - }, - "tips": { - "title": "Advanced Search Tips", - "tag": "<1>[tag] search withing a tag", - "user": "<1>user:username search by author", - "answer": "<1>answers:0 unanswered questions", - "score": "<1>score:3 posts with a 3+ score", - "question": "<1>is:question search questions", - "is_answer": "<1>is:answer search answers" - }, - "empty": "We couldn't find anything.
Try different or less specific keywords." - }, - "share": { - "name": "Share", - "copy": "Copy link", - "via": "Share post via...", - "copied": "Copied", - "facebook": "Share to Facebook", - "twitter": "Share to Twitter" - }, - "cannot_vote_for_self": "You can't vote for your own post", - "modal_confirm": { - "title": "Error..." - }, - "account_result": { - "page_title": "Welcome to Answer", - "success": "Your new account is confirmed; you will be redirected to the home page.", - "link": "Continue to homepage", - "invalid": "Sorry, this account confirmation link is no longer valid. Perhaps your account is already active?", - "confirm_new_email": "Your email has been updated.", - "confirm_new_email_invalid": "Sorry, this confirmation link is no longer valid. Perhaps your email was already changed?" - }, - "question": { - "following_tags": "Following Tags", - "edit": "Edit", - "save": "Save", - "follow_tag_tip": "Follow tags to curate your list of questions.", - "hot_questions": "Hot Questions", - "all_questions": "All Questions", - "x_questions": "{{ count }} Questions", - "x_answers": "{{ count }} answers", - "questions": "Questions", - "answers": "Answers", - "newest": "Newest", - "active": "Active", - "frequent": "Frequent", - "score": "Score", - "unanswered": "Unanswered", - "modified": "modified", - "answered": "answered", - "asked": "asked", - "closed": "closed", - "follow_a_tag": "Follow a tag", - "more": "More" - }, - "personal": { - "overview": "Overview", - "answers": "Answers", - "answer": "answer", - "questions": "Questions", - "question": "question", - "bookmarks": "Bookmarks", - "reputation": "Reputation", - "comments": "Comments", - "votes": "Votes", - "newest": "Newest", - "score": "Score", - "edit_profile": "Edit Profile", - "visited_x_days": "Visited {{ count }} days", - "viewed": "Viewed", - "joined": "Joined", - "last_login": "Seen", - "about_me": "About Me", - "about_me_empty": "// Hello, World !", - "top_answers": "Top Answers", - "top_questions": "Top Questions", - "stats": "Stats", - "list_empty": "No posts found.
Perhaps you'd like to select a different tab?", - "accepted": "Accepted", - "answered": "answered", - "asked": "asked", - "upvote": "upvote", - "downvote": "downvote", - "mod_short": "Mod", - "mod_long": "Moderators", - "x_reputation": "reputation", - "x_votes": "votes received", - "x_answers": "answers", - "x_questions": "questions" - }, - "page_404": { - "description": "Unfortunately, this page doesn't exist.", - "back_home": "Back to homepage" - }, - "page_50X": { - "description": "The server encountered an error and could not complete your request.", - "back_home": "Back to homepage" - }, - "admin": { - "admin_header": { - "title": "Admin" - }, - "nav_menus": { - "dashboard": "Dashboard", - "contents": "Contents", - "questions": "Questions", - "answers": "Answers", - "users": "Users", - "flags": "Flags", - "settings": "Settings", - "general": "General", - "interface": "Interface", - "smtp": "SMTP" - }, - "dashboard": { - "title": "Dashboard", - "welcome": "Welcome to Answer Admin !", - "version": "Version" - }, - "flags": { - "title": "Flags", - "pending": "Pending", - "completed": "Completed", - "flagged": "Flagged", - "created": "Created", - "action": "Action", - "review": "Review" - }, - "change_modal": { - "title": "Change user status to...", - "btn_cancel": "Cancel", - "btn_submit": "Submit", - "normal_name": "normal", - "normal_description": "A normal user can ask and answer questions.", - "suspended_name": "suspended", - "suspended_description": "A suspended user can't log in.", - "deleted_name": "deleted", - "deleted_description": "Delete profile, authentication associations.", - "inactive_name": "inactive", - "inactive_description": "An inactive user must re-validate their email.", - "confirm_title": "Delete this user", - "confirm_content": "Are you sure you want to delete this user? This is permanent!", - "confirm_btn": "Delete", - "msg": { - "empty": "Please select a reason." - } - }, - "status_modal": { - "title": "Change {{ type }} status to...", - "normal_name": "normal", - "normal_description": "A normal post available to everyone.", - "closed_name": "closed", - "closed_description": "A closed question can't answer, but still can edit, vote and comment.", - "deleted_name": "deleted", - "deleted_description": "All reputation gained and lost will be restored.", - "btn_cancel": "Cancel", - "btn_submit": "Submit", - "btn_next": "Next" - }, - "users": { - "title": "Users", - "name": "Name", - "email": "Email", - "reputation": "Reputation", - "created_at": "Created Time", - "delete_at": "Deleted Time", - "suspend_at": "Suspended Time", - "status": "Status", - "action": "Action", - "change": "Change", - "all": "All", - "inactive": "Inactive", - "suspended": "Suspended", - "deleted": "Deleted", - "normal": "Normal" - }, - "questions": { - "page_title": "Questions", - "normal": "Normal", - "closed": "Closed", - "deleted": "Deleted", - "post": "Post", - "votes": "Votes", - "answers": "Answers", - "created": "Created", - "status": "Status", - "action": "Action", - "change": "Change" - }, - "answers": { - "page_title": "Answers", - "normal": "Normal", - "deleted": "Deleted", - "post": "Post", - "votes": "Votes", - "created": "Created", - "status": "Status", - "action": "Action", - "change": "Change" - }, - "general": { - "page_title": "General", - "name": { - "label": "Site Name", - "msg": "Site name cannot be empty.", - "text": "The name of this site, as used in the title tag." - }, - "short_description": { - "label": "Short Site Description (optional)", - "msg": "Short site description cannot be empty.", - "text": "Short description, as used in the title tag on homepage." - }, - "description": { - "label": "Site Description (optional)", - "msg": "Site description cannot be empty.", - "text": "Describe this site in one sentence, as used in the meta description tag." - } - }, - "interface": { - "page_title": "Interface", - "logo": { - "label": "Logo (optional)", - "msg": "Site logo cannot be empty.", - "text": "You can upload your image or <1>reset it to the site title text." - }, - "theme": { - "label": "Theme", - "msg": "Theme cannot be empty.", - "text": "Select an existing theme." - }, - "language": { - "label": "Interface Language", - "msg": "Interface language cannot be empty.", - "text": "User interface language. It will change when you refresh the page." - } - }, - "smtp": { - "page_title": "SMTP", - "from_email": { - "label": "From Email", - "msg": "From email cannot be empty.", - "text": "The email address which emails are sent from." - }, - "from_name": { - "label": "From Name", - "msg": "From name cannot be empty.", - "text": "The name which emails are sent from." - }, - "smtp_host": { - "label": "SMTP Host", - "msg": "SMTP host cannot be empty.", - "text": "Your mail server." - }, - "encryption": { - "label": "Encryption", - "msg": "Encryption cannot be empty.", - "text": "For most servers SSL is the recommended option.", - "ssl": "SSL", - "none": "None" - }, - "smtp_port": { - "label": "SMTP Port", - "msg": "SMTP port must be number 1 ~ 65535.", - "text": "The port to your mail server." - }, - "smtp_username": { - "label": "SMTP Username", - "msg": "SMTP username cannot be empty." - }, - "smtp_password": { - "label": "SMTP Password", - "msg": "SMTP password cannot be empty." - }, - "test_email_recipient": { - "label": "Test Email Recipients", - "text": "Provide email address that will receive test sends.", - "msg": "Test email recipients is invalid" - }, - "smtp_authentication": { - "label": "SMTP Authentication", - "msg": "SMTP authentication cannot be empty.", - "yes": "Yes", - "no": "No" - } - } - } -} diff --git a/ui/src/i18n/locales/zh_CN.json b/ui/src/i18n/locales/zh_CN.json deleted file mode 100644 index a5e25ea3..00000000 --- a/ui/src/i18n/locales/zh_CN.json +++ /dev/null @@ -1,914 +0,0 @@ -{ - "how_to_format": { - "title": "如何设定文本格式", - "description": "
  • 添加链接:

    <https://url.com>

    [标题](https://url.com)
  • 段落之间使用空行分隔

  • _斜体_ 或者 **粗体**

  • 使用 4 个空格缩进代码

  • 在行首添加>表示引用

  • 反引号进行转义 `像 _这样_`

  • 使用```创建代码块

    ```
    // 这是代码
    ```
" - }, - "pagination": { - "prev": "上一页", - "next": "下一页" - }, - "page_title": { - "question": "问题", - "questions": "问题", - "tag": "标签", - "tags": "标签", - "tag_wiki": "标签 wiki", - "edit_tag": "编辑标签", - "ask_a_question": "提问题", - "edit_question": "编辑问题", - "edit_answer": "编辑回答", - "search": "搜索", - "posts_containing": "包含", - "settings": "设定", - "notifications": "通知", - "login": "登录", - "sign_up": "注册", - "account_recovery": "账号恢复", - "account_activation": "账号激活", - "confirm_email": "确认电子邮件", - "account_suspended": "账号已封禁", - "admin": "后台管理" - }, - "notifications": { - "title": "通知", - "inbox": "收件箱", - "achievement": "成就", - "all_read": "全部标记为已读", - "show_more": "显示更多" - }, - "suspended": { - "title": "账号已封禁", - "until_time": "你的账号被封禁至{{ time }}。", - "forever": "你的账号已被永久封禁。", - "end": "违反了我们的社区准则。" - }, - "editor": { - "blockquote": { - "text": "引用" - }, - "bold": { - "text": "粗体" - }, - "chart": { - "text": "图表", - "flow_chart": "流程图", - "sequence_diagram": "时序图", - "class_diagram": "类图", - "state_diagram": "状态图", - "entity_relationship_diagram": "ER 图", - "user_defined_diagram": "User defined diagram", - "gantt_chart": "甘特图", - "pie_chart": "饼图" - }, - "code": { - "text": "代码块", - "add_code": "添加代码块", - "form": { - "fields": { - "code": { - "label": "代码块", - "msg": { - "empty": "代码块不能为空" - } - }, - "language": { - "label": "语言 (可选)", - "placeholder": "自动识别" - } - } - }, - "btn_cancel": "取消", - "btn_confirm": "添加" - }, - "formula": { - "text": "公式", - "options": { - "inline": "行内公式", - "block": "公式块" - } - }, - "heading": { - "text": "标题", - "options": { - "h1": "标题 1", - "h2": "标题 2", - "h3": "标题 3", - "h4": "标题 4", - "h5": "标题 5", - "h6": "标题 6" - } - }, - "help": { - "text": "帮助" - }, - "hr": { - "text": "水平分割线" - }, - "image": { - "text": "图片", - "add_image": "添加图片", - "tab_image": "上传图片", - "form_image": { - "fields": { - "file": { - "label": "图片文件", - "btn": "选择图片", - "msg": { - "empty": "请选择图片文件。", - "only_image": "只能上传图片文件。", - "max_size": "图片文件大小不能超过 4 MB。" - } - }, - "description": { - "label": "图片描述(可选)" - } - } - }, - "tab_url": "网络图片", - "form_url": { - "fields": { - "url": { - "label": "图片地址", - "msg": { - "empty": "图片地址不能为空" - } - }, - "name": { - "label": "图片描述(可选)" - } - } - }, - "btn_cancel": "取消", - "btn_confirm": "添加", - "uploading": "上传中..." - }, - "indent": { - "text": "添加缩进" - }, - "outdent": { - "text": "减少缩进" - }, - "italic": { - "text": "斜体" - }, - "link": { - "text": "超链接", - "add_link": "添加超链接", - "form": { - "fields": { - "url": { - "label": "链接", - "msg": { - "empty": "链接不能为空。" - } - }, - "name": { - "label": "链接描述(可选)" - } - } - }, - "btn_cancel": "取消", - "btn_confirm": "添加" - }, - "ordered_list": { - "text": "有编号列表" - }, - "unordered_list": { - "text": "无编号列表" - }, - "table": { - "text": "表格", - "heading": "表头", - "cell": "单元格" - } - }, - "close_modal": { - "title": "关闭原因是...", - "btn_cancel": "取消", - "btn_submit": "提交", - "remark": { - "empty": "不能为空。" - }, - "msg": { - "empty": "请选择一个原因。" - } - }, - "report_modal": { - "flag_title": "举报原因是...", - "close_title": "关闭原因是...", - "review_question_title": "审查问题", - "review_answer_title": "审查回答", - "review_comment_title": "审查评论", - "btn_cancel": "取消", - "btn_submit": "提交", - "remark": { - "empty": "不能为空" - }, - "msg": { - "empty": "请选择一个原因。" - } - }, - "tag_modal": { - "title": "创建新标签", - "form": { - "fields": { - "display_name": { - "label": "显示名称(别名)", - "msg": { - "empty": "不能为空", - "range": "不能超过 35 个字符" - } - }, - "slug_name": { - "label": "URL 固定链接", - "description": "必须由 \"a-z\", \"0-9\", \"+ # - .\" 组成", - "msg": { - "empty": "不能为空", - "range": "不能超过 35 个字符", - "character": "包含非法字符" - } - }, - "description": { - "label": "标签描述(可选)" - } - } - }, - "btn_cancel": "取消", - "btn_submit": "提交" - }, - "tag_info": { - "created_at": "创建于", - "edited_at": "编辑于", - "synonyms": { - "title": "同义词", - "text": "以下标签等同于", - "empty": "此标签目前没有同义词。", - "btn_add": "添加同义词", - "btn_edit": "编辑", - "btn_save": "保存" - }, - "synonyms_text": "以下标签等同于", - "delete": { - "title": "删除标签", - "content": "

不允许删除有关联问题的标签。

请先从关联的问题中删除此标签的引用。

", - "content2": "确定要删除吗?", - "close": "关闭" - } - }, - "edit_tag": { - "title": "编辑标签", - "default_reason": "编辑标签", - "form": { - "fields": { - "revision": { - "label": "编辑历史" - }, - "display_name": { - "label": "名称" - }, - "slug_name": { - "label": "URL 固定链接", - "info": "必须由 \"a-z\", \"0-9\", \"+ # - .\" 组成" - }, - "description": { - "label": "描述" - }, - "edit_summary": { - "label": "编辑概要", - "placeholder": "简单描述更改原因 (错别字、文字表达、格式等等)" - } - } - }, - "btn_save_edits": "保存更改", - "btn_cancel": "取消" - }, - "dates": { - "long_date": "YYYY年MM月", - "long_date_with_year": "YYYY年MM月DD日", - "long_date_with_time": "YYYY年MM月DD日 HH:mm", - "now": "刚刚", - "x_seconds_ago": "{{count}} 秒前", - "x_minutes_ago": "{{count}} 分钟前", - "x_hours_ago": "{{count}} 小时前" - }, - "comment": { - "btn_add_comment": "添加评论", - "reply_to": "回复", - "btn_reply": "回复", - "btn_edit": "编辑", - "btn_delete": "删除", - "btn_flag": "举报", - "btn_save_edits": "保存", - "btn_cancel": "取消", - "show_more": "显示更多评论", - "tip_question": "使用评论提问更多信息或者提出改进意见。尽量避免使用评论功能回答问题。", - "tip_answer": "使用评论对回答者进行回复,或者通知回答者你已更新了问题的内容。如果要补充或者完善问题的内容,请在原问题中更改。" - }, - "edit_answer": { - "title": "编辑回答", - "default_reason": "编辑回答", - "form": { - "fields": { - "revision": { - "label": "编辑历史" - }, - "answer": { - "label": "回答内容" - }, - "edit_summary": { - "label": "编辑概要", - "placeholder": "简单描述更改原因 (错别字、文字表达、格式等等)" - } - } - }, - "btn_save_edits": "保存更改", - "btn_cancel": "取消" - }, - "tags": { - "title": "标签", - "sort_buttons": { - "popular": "热门", - "name": "名称", - "newest": "最新" - }, - "button_follow": "关注", - "button_following": "已关注", - "tag_label": "个问题", - "search_placeholder": "通过标签名过滤", - "no_description": "此标签无描述。", - "more": "更多" - }, - "ask": { - "title": "提交新的问题", - "edit_title": "编辑问题", - "default_reason": "编辑问题", - "similar_questions": "相似的问题", - "form": { - "fields": { - "revision": { - "label": "编辑历史" - }, - "title": { - "label": "标题", - "placeholder": "请详细描述你的问题", - "msg": { - "empty": "标题不能为空", - "range": "标题最多 150 个字符" - } - }, - "body": { - "label": "内容", - "msg": { - "empty": "内容不能为空" - } - }, - "tags": { - "label": "标签", - "msg": { - "empty": "必须选择一个标签" - } - }, - "answer": { - "label": "回答内容", - "msg": { - "empty": "回答内容不能为空" - } - } - } - }, - "btn_post_question": "提交问题", - "btn_save_edits": "保存更改", - "answer_question": "直接发表回答", - "post_question&answer": "提交问题和回答" - }, - "tag_selector": { - "add_btn": "添加标签", - "create_btn": "创建新标签", - "search_tag": "搜索标签", - "hint": "选择至少一个与问题相关的标签。", - "no_result": "没有匹配的标签" - }, - "header": { - "nav": { - "question": "问题", - "tag": "标签", - "user": "用户", - "profile": "用户主页", - "setting": "账号设置", - "logout": "退出登录", - "admin": "后台管理" - }, - "search": { - "placeholder": "搜索" - } - }, - "footer": { - "build_on": "Built on <1> Answer - the open-source software that power Q&A communities
Made with love © 2022 Answer" - }, - "upload_img": { - "name": "更改图片", - "loading": "加载中..." - }, - "pic_auth_code": { - "title": "验证码", - "placeholder": "输入图片中的文字", - "msg": { - "empty": "不能为空" - } - }, - "inactive": { - "first": "马上就好了!我们发送了一封激活邮件到 {{mail}}。请按照邮件中的说明激活您的帐户。", - "info": "如果没有收到,请检查您的垃圾邮件文件夹。", - "another": "我们向您发送了另一封激活电子邮件,地址为 {{mail}}。它可能需要几分钟才能到达;请务必检查您的垃圾邮件文件夹。", - "btn_name": "重新发送激活邮件", - "msg": { - "empty": "不能为空" - } - }, - "login": { - "page_title": "欢迎来到 Answer", - "info_sign": "没有帐户?<1>注册", - "info_login": "已经有一个帐户?<1>登录", - "forgot_pass": "忘记密码?", - "name": { - "label": "昵称", - "msg": { - "empty": "昵称不能为空", - "range": "昵称最多 30 个字符" - } - }, - "email": { - "label": "邮箱", - "msg": { - "empty": "邮箱不能为空" - } - }, - "password": { - "label": "密码", - "msg": { - "empty": "密码不能为空", - "different": "两次输入密码不一致" - } - } - }, - "account_forgot": { - "page_title": "忘记密码", - "btn_name": "发送恢复邮件", - "send_success": "如无意外,你的邮箱 {{mail}} 将会收到一封重置密码的邮件,请根据指引重置你的密码。", - "email": { - "label": "邮箱", - "msg": { - "empty": "邮箱不能为空" - } - } - }, - "password_reset": { - "page_title": "密码重置", - "btn_name": "重置我的密码", - "reset_success": "你已经成功更改密码,将返回登录页面", - "link_invalid": "抱歉,此密码重置链接已失效。也许是你已经重置过密码了?", - "to_login": "前往登录页面", - "password": { - "label": "密码", - "msg": { - "empty": "密码不能为空", - "length": "密码长度在8-32个字符之间", - "different": "两次输入密码不一致" - } - }, - "password_confirm": { - "label": "确认新密码" - } - }, - "settings": { - "page_title": "设置", - "nav": { - "profile": "我的资料", - "notification": "通知", - "account": "账号", - "interface": "界面" - }, - "profile": { - "btn_name": "保存更改", - "display_name": { - "label": "昵称", - "msg": "昵称不能为空", - "msg_range": "昵称不能超过 30 个字符" - }, - "username": { - "label": "用户名", - "caption": "用户之间可以通过 \"@用户名\" 进行交互。", - "msg": "用户名不能为空", - "msg_range": "用户名不能超过 30 个字符", - "character": "用户名只能由 \"a-z\", \"0-9\", \" - . _\" 组成" - }, - "avatar": { - "label": "头像", - "text": "您可以上传图片作为头像,也可以 <1>重置 为" - }, - "bio": { - "label": "关于我 (可选)" - }, - "website": { - "label": "网站 (可选)", - "placeholder": "https://example.com", - "msg": "格式不正确" - }, - "location": { - "label": "位置 (可选)", - "placeholder": "城市, 国家" - } - }, - "notification": { - "email": { - "label": "邮件通知", - "radio": "你的提问有新的回答,评论,和其他" - } - }, - "account": { - "change_email_btn": "更改邮箱", - "change_pass_btn": "更改密码", - "change_email_info": "邮件已发送。请根据指引完成验证。", - "email": { - "label": "邮箱", - "msg": "邮箱不能为空" - }, - "password_title": "密码", - "current_pass": { - "label": "当前密码", - "msg": { - "empty": "当前密码不能为空", - "length": "密码长度必须在 8 至 32 之间", - "different": "两次输入的密码不匹配" - } - }, - "new_pass": { - "label": "新密码" - }, - "pass_confirm": { - "label": "确认新密码" - } - }, - "interface": { - "lang": { - "label": "界面语言", - "text": "设置用户界面语言,在刷新页面后生效。" - } - } - }, - "toast": { - "update": "更新成功", - "update_password": "更改密码成功。", - "flag_success": "感谢您的标记,我们会尽快处理。" - }, - "related_question": { - "title": "相关问题", - "btn": "我要提问", - "answers": "个回答" - }, - "question_detail": { - "Asked": "提问于", - "asked": "提问于", - "update": "修改于", - "edit": "最后编辑于", - "Views": "阅读次数", - "Follow": "关注此问题", - "Following": "已关注", - "answered": "回答于", - "closed_in": "关闭于", - "show_exist": "查看相关问题。", - "answers": { - "title": "个回答", - "score": "评分", - "newest": "最新", - "btn_accept": "采纳", - "btn_accepted": "已被采纳" - }, - "write_answer": { - "title": "你的回答", - "btn_name": "提交你的回答", - "confirm_title": "继续回答", - "continue": "继续", - "confirm_info": "

您确定要提交一个新的回答吗?

您可以直接编辑和改善您之前的回答的。

", - "empty": "回答内容不能为空。" - } - }, - "delete": { - "title": "删除", - "question": "我们不建议删除有回答的帖子。因为这样做会使得后来的读者无法从该问题中获得帮助。

如果删除过多有回答的帖子,你的账号将会被禁止提问。你确定要删除吗?", - "answer_accepted": "

我们不建议删除被采纳的回答。因为这样做会使得后来的读者无法从该回答中获得帮助。

如果删除过多被采纳的回答,你的账号将会被禁止回答任何提问。你确定要删除吗?", - "other": "你确定要删除?", - "tip_question_deleted": "此问题已被删除", - "tip_answer_deleted": "此回答已被删除" - }, - "btns": { - "confirm": "确认", - "cancel": "取消", - "save": "保存", - "delete": "删除", - "login": "登录", - "signup": "注册", - "logout": "退出登录", - "verify": "验证", - "add_question": "我要提问" - }, - "search": { - "title": "搜索结果", - "keywords": "关键词", - "options": "选项", - "follow": "关注", - "following": "已关注", - "counts": "{{count}} 个结果", - "more": "更多", - "sort_btns": { - "relevance": "相关性", - "newest": "最新的", - "active": "活跃的", - "score": "评分" - }, - "tips": { - "title": "高级搜索提示", - "tag": "<1>[tag] 在指定标签中搜索", - "user": "<1>user:username 根据作者搜索", - "answer": "<1>answers:0 搜索未回答的问题", - "score": "<1>score:3 评分 3 分或以上", - "question": "<1>is:question 只搜索问题", - "is_answer": "<1>is:answer 只搜索回答" - }, - "empty": "找不到任何相关的内容。
请尝试其他关键字,或者减少查找内容的长度。" - }, - "share": { - "name": "分享", - "copy": "复制链接", - "via": "分享在...", - "copied": "已复制", - "facebook": "分享到 Facebook", - "twitter": "分享到 Twitter" - }, - "cannot_vote_for_self": "不能给自己投票", - "modal_confirm": { - "title": "发生错误..." - }, - "account_result": { - "page_title": "欢迎来到 Answer", - "success": "你的账号已通过验证,即将返回首页。", - "link": "返回首页", - "invalid": "抱歉,此验证链接已失效。也许是你的账号已经通过验证了?", - "confirm_new_email": "你的电子邮箱已更新", - "confirm_new_email_invalid": "抱歉,此验证链接已失效。也许是你的邮箱已经成功更改了?" - }, - "question": { - "following_tags": "已关注的标签", - "edit": "编辑", - "save": "保存", - "follow_tag_tip": "按照标签整理您的问题列表。", - "hot_questions": "热点问题", - "all_questions": "全部问题", - "x_questions": "{{ count }} 个问题", - "x_answers": "{{ count }} 个回答", - "questions": "个问题", - "answers": "回答", - "newest": "最新", - "active": "活跃", - "frequent": "浏览量", - "score": "评分", - "unanswered": "未回答", - "modified": "修改于", - "answered": "回答于", - "asked": "提问于", - "closed": "已关闭", - "follow_a_tag": "关注一个标签", - "more": "更多" - }, - "personal": { - "overview": "概览", - "answers": "回答", - "answer": "回答", - "questions": "问题", - "question": "问题", - "bookmarks": "收藏", - "reputation": "声望", - "comments": "评论", - "votes": "得票", - "newest": "最新", - "score": "评分", - "edit_profile": "编辑我的资料", - "visited_x_days": "Visited {{ count }} days", - "viewed": "Viewed", - "joined": "加入于", - "last_login": "上次登录", - "about_me": "关于我", - "about_me_empty": "// Hello, World !", - "top_answers": "热门回答", - "top_questions": "热门问题", - "stats": "状态", - "list_empty": "没有找到相关的内容。
试试看其他标签?", - "accepted": "已采纳", - "answered": "回答于", - "asked": "提问于", - "upvote": "赞同", - "downvote": "反对", - "mod_short": "管理员", - "mod_long": "管理员", - "x_reputation": "声望", - "x_votes": "得票", - "x_answers": "个回答", - "x_questions": "个问题" - }, - "page_404": { - "description": "页面不存在", - "back_home": "回到主页" - }, - "page_50X": { - "description": "服务器遇到了一个错误,无法完成你的请求。", - "back_home": "回到主页" - }, - "admin": { - "admin_header": { - "title": "后台管理" - }, - "nav_menus": { - "dashboard": "后台管理", - "contents": "内容管理", - "questions": "问题", - "answers": "回答", - "users": "用户管理", - "flags": "举报管理", - "settings": "站点设置", - "general": "一般", - "interface": "界面", - "smtp": "SMTP" - }, - "dashboard": { - "title": "后台管理", - "welcome": "欢迎来到 Answer 后台管理!", - "version": "版本" - }, - "flags": { - "title": "举报", - "pending": "等待处理", - "completed": "已完成", - "flagged": "被举报内容", - "created": "创建于", - "action": "操作", - "review": "审查" - }, - "change_modal": { - "title": "更改用户状态为...", - "btn_cancel": "取消", - "btn_submit": "提交", - "normal_name": "正常", - "normal_description": "正常状态的用户可以提问和回答。", - "suspended_name": "封禁", - "suspended_description": "被封禁的用户将无法登录。", - "deleted_name": "删除", - "deleted_description": "删除用户的个人信息,认证等等。", - "inactive_name": "不活跃", - "inactive_description": "不活跃的用户必须重新验证邮箱。", - "confirm_title": "删除此用户", - "confirm_content": "确定要删除此用户?此操作无法撤销!", - "confirm_btn": "删除", - "msg": { - "empty": "请选择一个原因" - } - }, - "status_modal": { - "title": "更改 {{ type }} 状态为...", - "normal_name": "正常", - "normal_description": "所有用户都可以访问", - "closed_name": "关闭", - "closed_description": "不能回答,但仍然可以编辑、投票和评论。", - "deleted_name": "删除", - "deleted_description": "所有获得/损失的声望将会恢复。", - "btn_cancel": "取消", - "btn_submit": "提交", - "btn_next": "下一步" - }, - "users": { - "title": "用户", - "name": "名称", - "email": "邮箱", - "reputation": "声望", - "created_at": "创建时间", - "delete_at": "删除时间", - "suspend_at": "封禁时间", - "status": "状态", - "action": "操作", - "change": "更改", - "all": "全部", - "inactive": "不活跃", - "suspended": "已封禁", - "deleted": "已删除", - "normal": "正常" - }, - "questions": { - "page_title": "问题", - "normal": "正常", - "closed": "已关闭", - "deleted": "已删除", - "post": "标题", - "votes": "得票数", - "answers": "回答数", - "created": "创建于", - "status": "状态", - "action": "操作", - "change": "更改" - }, - "answers": { - "page_title": "回答", - "normal": "正常", - "deleted": "已删除", - "post": "标题", - "votes": "得票数", - "created": "创建于", - "status": "状态", - "action": "操作", - "change": "更改" - }, - "general": { - "page_title": "一般", - "name": { - "label": "站点名称", - "msg": "不能为空", - "text": "站点的名称,作为站点的标题(HTML 的 title 标签)。" - }, - "short_description": { - "label": "简短的站点标语 (可选)", - "msg": "不能为空", - "text": "简短的标语,作为网站主页的标题(HTML 的 title 标签)。" - }, - "description": { - "label": "网站描述 (可选)", - "msg": "不能为空", - "text": "使用一句话描述本站,作为网站的描述(HTML 的 meta 标签)。" - } - }, - "interface": { - "page_title": "界面", - "logo": { - "label": "Logo (可选)", - "msg": "不能为空", - "text": "可以上传图片,或者<1>重置为站点标题。" - }, - "theme": { - "label": "主题", - "msg": "不能为空", - "text": "选择一个主题" - }, - "language": { - "label": "界面语言", - "msg": "不能为空", - "text": "设置用户界面语言,在刷新页面后生效。" - } - }, - "smtp": { - "page_title": "SMTP", - "from_email": { - "label": "发件人地址", - "msg": "不能为空", - "text": "用于发送邮件的地址。" - }, - "from_name": { - "label": "发件人名称", - "msg": "不能为空", - "text": "发件人的名称" - }, - "smtp_host": { - "label": "SMTP 主机", - "msg": "不能为空", - "text": "邮件服务器" - }, - "encryption": { - "label": "加密", - "msg": "不能为空", - "text": "对于大多数服务器而言,SSL 是推荐开启的。", - "ssl": "SSL", - "none": "无加密" - }, - "smtp_port": { - "label": "SMTP 端口", - "msg": "SMTP 端口必须在 1 ~ 65535 之间。", - "text": "邮件服务器的端口号。" - }, - "smtp_username": { - "label": "SMTP 用户名", - "msg": "不能为空" - }, - "smtp_password": { - "label": "SMTP 密码", - "msg": "不能为空" - }, - "test_email_recipient": { - "label": "测试邮件收件人", - "text": "提供用于接收测试邮件的邮箱地址。", - "msg": "地址无效" - }, - "smtp_authentication": { - "label": "SMTP 认证", - "msg": "不能为空", - "yes": "是", - "no": "否" - } - } - } -} diff --git a/ui/src/index.scss b/ui/src/index.scss index e81a900c..097dafa0 100644 --- a/ui/src/index.scss +++ b/ui/src/index.scss @@ -75,7 +75,11 @@ a { } .page-wrap { - min-height: calc(100vh - 148px); + min-height: calc(100vh - 138px); +} +.page-wrap2 { + background-color: #f5f5f5; + min-height: 100vh; } .btn-no-border, @@ -137,6 +141,11 @@ a { background-color: #fff3cd80; } +.fit-content { + height: fit-content; + flex: none; +} + // fix bug for React-Bootstrap Form.Text .form-text { display: inline-block; diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 8553972a..93bc4ed8 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -2,15 +2,23 @@ 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( - - - , -); + +async function bootstrapApp() { + await guard.setupApp(); + root.render( + + + , + ); +} + +bootstrapApp(); diff --git a/ui/src/pages/Admin/Answers/index.tsx b/ui/src/pages/Admin/Answers/index.tsx index f9a9de27..3221f60f 100644 --- a/ui/src/pages/Admin/Answers/index.tsx +++ b/ui/src/pages/Admin/Answers/index.tsx @@ -11,21 +11,24 @@ 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 { escapeRemove } from '@/utils'; 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 +39,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 +82,11 @@ const Answers: FC = () => { }); }; + const handleFilter = (e) => { + urlSearchParams.set('query', e.target.value); + urlSearchParams.delete('page'); + setUrlSearchParams(urlSearchParams); + }; return ( <>

{t('page_title')}

@@ -89,21 +99,24 @@ const Answers: FC = () => { />
- +
- - - - - {curFilter !== 'deleted' && } + + + + + {curFilter !== 'deleted' && ( + + )} @@ -128,11 +141,10 @@ const Answers: FC = () => { )}
+ className="text-truncate-2 fs-14" + style={{ maxWidth: '30rem' }}> + {escapeRemove(li.description)} +
@@ -153,7 +165,10 @@ const Answers: FC = () => { {curFilter !== 'deleted' && ( diff --git a/ui/src/pages/Admin/Dashboard/components/AnswerLinks/index.tsx b/ui/src/pages/Admin/Dashboard/components/AnswerLinks/index.tsx new file mode 100644 index 00000000..8f5048f8 --- /dev/null +++ b/ui/src/pages/Admin/Dashboard/components/AnswerLinks/index.tsx @@ -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 ( + + +
{t('answer_links')}
+ +
+ + {t('documents')} + + + + + {t('feedback')} + + + + + + ); +}; + +export default AnswerLinks; diff --git a/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx b/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx new file mode 100644 index 00000000..fd511ca1 --- /dev/null +++ b/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx @@ -0,0 +1,70 @@ +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 = ({ data }) => { + const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' }); + const { version, remote_version } = data.version_info || {}; + const isLatest = version === remote_version; + return ( + + +
{t('site_health_status')}
+ +
+ {t('version')} + {version} + {isLatest && ( + + {t('latest')} + + )} + {!isLatest && remote_version && ( + + {t('update_to')} {remote_version} + + )} + {!isLatest && !remote_version && ( + + {t('check_failed')} + + )} + + + {t('https')} + {data.https ? t('yes') : t('yes')} + + + {t('uploading_files')} + + {data.uploading_files ? t('allowed') : t('not_allowed')} + + + + {t('smtp')} + {data.smtp ? ( + {t('enabled')} + ) : ( + + {t('config')} + + )} + + + {t('timezone')} + {data.time_zone} + + + + + ); +}; + +export default HealthStatus; diff --git a/ui/src/pages/Admin/Dashboard/components/Statistics/index.tsx b/ui/src/pages/Admin/Dashboard/components/Statistics/index.tsx new file mode 100644 index 00000000..c12bc320 --- /dev/null +++ b/ui/src/pages/Admin/Dashboard/components/Statistics/index.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react'; +import { Card, Row, Col } 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 Statistics: FC = ({ data }) => { + const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' }); + + return ( + + +
{t('site_statistics')}
+ +
+ {t('questions')} + {data.question_count} + + + {t('answers')} + {data.answer_count} + + + {t('comments')} + {data.comment_count} + + + {t('votes')} + {data.vote_count} + + + {t('active_users')} + {data.user_count} + + + {t('flags')} + + + {data.report_count} + + + + + + + ); +}; + +export default Statistics; diff --git a/ui/src/pages/Admin/Dashboard/components/SystemInfo/index.tsx b/ui/src/pages/Admin/Dashboard/components/SystemInfo/index.tsx new file mode 100644 index 00000000..cbc065c7 --- /dev/null +++ b/ui/src/pages/Admin/Dashboard/components/SystemInfo/index.tsx @@ -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 = ({ data }) => { + const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' }); + + return ( + + +
{t('system_info')}
+ +
+ {t('storage_used')} + {data.occupying_storage_space} + + + {t('uptime')} + {formatUptime(data.app_start_time)} + + + + + ); +}; + +export default SystemInfo; diff --git a/ui/src/pages/Admin/Dashboard/components/index.ts b/ui/src/pages/Admin/Dashboard/components/index.ts new file mode 100644 index 00000000..877f643f --- /dev/null +++ b/ui/src/pages/Admin/Dashboard/components/index.ts @@ -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 }; diff --git a/ui/src/pages/Admin/Dashboard/index.tsx b/ui/src/pages/Admin/Dashboard/index.tsx index 45c19721..0b6834f9 100644 --- a/ui/src/pages/Admin/Dashboard/index.tsx +++ b/ui/src/pages/Admin/Dashboard/index.tsx @@ -1,18 +1,42 @@ 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 ( <>

{t('title')}

{t('welcome')}

- {process.env.REACT_APP_VERSION && ( -

- {`${t('version')} `} - {process.env.REACT_APP_VERSION} -

- )} + + + + + + + + + + + + + + ); }; diff --git a/ui/src/pages/Admin/Flags/index.tsx b/ui/src/pages/Admin/Flags/index.tsx index 5eb14ec4..ff85ea1e 100644 --- a/ui/src/pages/Admin/Flags/index.tsx +++ b/ui/src/pages/Admin/Flags/index.tsx @@ -9,10 +9,11 @@ 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 { escapeRemove } from '@/utils'; import '../index.scss'; @@ -107,7 +108,7 @@ const Flags: FC = () => { {li.title} - {li.excerpt} + {escapeRemove(li.excerpt)} diff --git a/ui/src/pages/Admin/General/index.tsx b/ui/src/pages/Admin/General/index.tsx index 87e473d3..2ea2f485 100644 --- a/ui/src/pages/Admin/General/index.tsx +++ b/ui/src/pages/Admin/General/index.tsx @@ -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'; @@ -23,6 +23,11 @@ const General: FC = () => { isInvalid: false, errorMsg: '', }, + site_url: { + value: '', + isInvalid: false, + errorMsg: '', + }, short_description: { value: '', isInvalid: false, @@ -33,10 +38,15 @@ const General: FC = () => { isInvalid: false, errorMsg: '', }, + contact_email: { + value: '', + isInvalid: false, + errorMsg: '', + }, }); const checkValidated = (): boolean => { let ret = true; - const { name } = formData; + const { name, site_url, contact_email } = formData; if (!name.value) { ret = false; formData.name = { @@ -45,6 +55,41 @@ const General: FC = () => { errorMsg: t('name.msg'), }; } + if (!site_url.value) { + ret = false; + formData.site_url = { + value: '', + isInvalid: true, + errorMsg: t('site_url.msg'), + }; + } else if (!/^(https?):\/\/([\w.]+\/?)\S*$/.test(site_url.value)) { + ret = false; + formData.site_url = { + value: formData.site_url.value, + isInvalid: true, + errorMsg: t('site_url.validate'), + }; + } + + if (!contact_email.value) { + ret = false; + formData.contact_email = { + value: '', + isInvalid: true, + errorMsg: t('contact_email.msg'), + }; + } else if ( + !/^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test( + contact_email.value, + ) + ) { + ret = false; + formData.contact_email = { + value: formData.contact_email.value, + isInvalid: true, + errorMsg: t('contact_email.validate'), + }; + } setFormData({ ...formData, }); @@ -61,6 +106,8 @@ const General: FC = () => { name: formData.name.value, description: formData.description.value, short_description: formData.short_description.value, + site_url: formData.site_url.value, + contact_email: formData.contact_email.value, }; updateGeneralSetting(reqParams) @@ -100,7 +147,7 @@ const General: FC = () => { Object.keys(setting).forEach((k) => { formMeta[k] = { ...formData[k], value: setting[k] }; }); - setFormData(formMeta); + setFormData({ ...formData, ...formMeta }); }, [setting]); return ( <> @@ -120,6 +167,20 @@ const General: FC = () => { {formData.name.errorMsg} + + {t('site_url.label')} + onFieldChange('site_url', evt.target.value)} + /> + {t('site_url.text')} + + {formData.site_url.errorMsg} + + {t('short_description.label')} { {formData.description.errorMsg} + + {t('contact_email.label')} + onFieldChange('contact_email', evt.target.value)} + /> + {t('contact_email.text')} + + {formData.contact_email.errorMsg} + + diff --git a/ui/src/pages/Admin/Questions/index.tsx b/ui/src/pages/Admin/Questions/index.tsx index 373faf94..76a65de9 100644 --- a/ui/src/pages/Admin/Questions/index.tsx +++ b/ui/src/pages/Admin/Questions/index.tsx @@ -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 ( <>

{t('page_title')}

@@ -108,22 +115,25 @@ const Questions: FC = () => { />
{t('post')}{t('votes')}{t('created')}{t('status')}{t('action')}{t('post')}{t('votes')}{t('created')}{t('status')}{t('action')}
{li.vote_count} -
- - - + + + - - {curFilter !== 'deleted' && } + + {curFilter !== 'deleted' && ( + + )} @@ -147,12 +157,11 @@ const Questions: FC = () => { {curFilter !== 'deleted' && ( diff --git a/ui/src/pages/Admin/Smtp/index.tsx b/ui/src/pages/Admin/Smtp/index.tsx index f6714255..33de30ab 100644 --- a/ui/src/pages/Admin/Smtp/index.tsx +++ b/ui/src/pages/Admin/Smtp/index.tsx @@ -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 = () => { diff --git a/ui/src/pages/Admin/Users/index.tsx b/ui/src/pages/Admin/Users/index.tsx index f15db7b5..58b849d1 100644 --- a/ui/src/pages/Admin/Users/index.tsx +++ b/ui/src/pages/Admin/Users/index.tsx @@ -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 ( <>

{t('title')}

@@ -71,31 +76,32 @@ const Users: FC = () => { /> setUserName(e.target.value)} - placeholder="Filter by name" + value={curQuery} + onChange={handleFilter} + placeholder={t('filter.placeholder')} style={{ width: '12.25rem' }} />
{t('post')}{t('votes')}{t('answers')}{t('post')}{t('votes')}{t('answers')} {t('created')}{t('status')}{t('action')}{t('status')}{t('action')}
{li.vote_count} - {li.answer_count} - + @@ -170,7 +179,10 @@ const Questions: FC = () => { -
- - + + - {(curFilter === 'deleted' || curFilter === 'suspended') && ( - )} - - {curFilter !== 'deleted' ? : null} + + {curFilter !== 'deleted' ? ( + + ) : null} @@ -132,7 +138,7 @@ const Users: FC = () => { diff --git a/ui/src/pages/Install/components/FifthStep/index.tsx b/ui/src/pages/Install/components/FifthStep/index.tsx new file mode 100644 index 00000000..ab94f30f --- /dev/null +++ b/ui/src/pages/Install/components/FifthStep/index.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; +import { Button } from 'react-bootstrap'; +import { useTranslation, Trans } from 'react-i18next'; + +import Progress from '../Progress'; + +interface Props { + visible: boolean; + siteUrl: string; +} +const Index: FC = ({ visible, siteUrl = '' }) => { + const { t } = useTranslation('translation', { keyPrefix: 'install' }); + + if (!visible) return null; + return ( +
+
{t('ready_title')}
+

+ + If you ever feel like changing more settings, visit + admin section; find it in the + site menu. + +

+

{t('good_luck')}

+ +
+ + +
+
+ ); +}; + +export default Index; diff --git a/ui/src/pages/Install/components/FirstStep/index.tsx b/ui/src/pages/Install/components/FirstStep/index.tsx new file mode 100644 index 00000000..78b4aac8 --- /dev/null +++ b/ui/src/pages/Install/components/FirstStep/index.tsx @@ -0,0 +1,75 @@ +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 { getInstallLangOptions } from '@/services'; + +interface Props { + data: FormValue; + changeCallback: (value: FormDataType) => void; + nextCallback: () => void; + visible: boolean; +} +const Index: FC = ({ visible, data, changeCallback, nextCallback }) => { + const { t } = useTranslation('translation', { keyPrefix: 'install' }); + + const [langs, setLangs] = useState(); + + const getLangs = async () => { + const res: LangsType[] = await getInstallLangOptions(); + setLangs(res); + changeCallback({ + lang: { + value: res[0].value, + isInvalid: false, + errorMsg: '', + }, + }); + }; + + const handleSubmit = () => { + nextCallback(); + }; + + useEffect(() => { + getLangs(); + }, []); + + if (!visible) return null; + return ( +
+ + {t('lang.label')} + { + changeCallback({ + lang: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }}> + {langs?.map((item) => { + return ( + + ); + })} + + + +
+ + +
+ + ); +}; + +export default Index; diff --git a/ui/src/pages/Install/components/FourthStep/index.tsx b/ui/src/pages/Install/components/FourthStep/index.tsx new file mode 100644 index 00000000..0f7d7b88 --- /dev/null +++ b/ui/src/pages/Install/components/FourthStep/index.tsx @@ -0,0 +1,265 @@ +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 = ({ visible, data, changeCallback, nextCallback }) => { + const { t } = useTranslation('translation', { keyPrefix: 'install' }); + + const checkValidated = (): boolean => { + let bol = true; + const { site_name, site_url, contact_email, name, password, email } = data; + + if (!site_name.value) { + bol = false; + data.site_name = { + value: '', + isInvalid: true, + errorMsg: t('site_name.msg'), + }; + } + + if (!site_url.value) { + bol = false; + data.site_url = { + value: '', + isInvalid: true, + errorMsg: t('site_name.msg.empty'), + }; + } + const reg = /^(http|https):\/\//g; + if (site_url.value && !site_url.value.match(reg)) { + bol = false; + data.site_url = { + value: site_url.value, + isInvalid: true, + errorMsg: t('site_url.msg.incorrect'), + }; + } + + if (!contact_email.value) { + bol = false; + data.contact_email = { + value: '', + isInvalid: true, + errorMsg: t('contact_email.msg.empty'), + }; + } + + const mailReg = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/; + if (contact_email.value && !contact_email.value.match(mailReg)) { + bol = false; + data.contact_email = { + value: contact_email.value, + isInvalid: true, + errorMsg: t('contact_email.msg.incorrect'), + }; + } + + if (!name.value) { + bol = false; + data.name = { + value: '', + isInvalid: true, + errorMsg: t('admin_name.msg'), + }; + } + + if (!password.value) { + bol = false; + data.password = { + value: '', + isInvalid: true, + errorMsg: t('admin_password.msg'), + }; + } + + if (!email.value) { + bol = false; + data.email = { + value: '', + isInvalid: true, + errorMsg: t('admin_email.msg.empty'), + }; + } + + if (email.value && !email.value.match(mailReg)) { + bol = false; + data.email = { + value: email.value, + isInvalid: true, + errorMsg: t('admin_email.msg.incorrect'), + }; + } + + changeCallback({ + ...data, + }); + return bol; + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!checkValidated()) { + return; + } + nextCallback(); + }; + + if (!visible) return null; + return ( +
+
{t('site_information')}
+ + {t('site_name.label')} + { + changeCallback({ + site_name: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + + {data.site_name.errorMsg} + + + + {t('site_url.label')} + { + changeCallback({ + site_url: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + {t('site_url.text')} + + {data.site_url.errorMsg} + + + + {t('contact_email.label')} + { + changeCallback({ + contact_email: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + {t('contact_email.text')} + + {data.contact_email.errorMsg} + + + +
{t('admin_account')}
+ + {t('admin_name.label')} + { + changeCallback({ + name: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + + {data.name.errorMsg} + + + + + {t('admin_password.label')} + { + changeCallback({ + password: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + {t('admin_password.text')} + + {data.password.errorMsg} + + + + + {t('admin_email.label')} + { + changeCallback({ + email: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + {t('admin_email.text')} + + {data.email.errorMsg} + + + +
+ + +
+ + ); +}; + +export default Index; diff --git a/ui/src/pages/Install/components/Progress/index.tsx b/ui/src/pages/Install/components/Progress/index.tsx new file mode 100644 index 00000000..97f33ffe --- /dev/null +++ b/ui/src/pages/Install/components/Progress/index.tsx @@ -0,0 +1,22 @@ +import { FC, memo } from 'react'; +import { ProgressBar } from 'react-bootstrap'; + +interface IProps { + step: number; +} + +const Index: FC = ({ step }) => { + return ( +
+ + {step}/5 +
+ ); +}; + +export default memo(Index); diff --git a/ui/src/pages/Install/components/SecondStep/index.tsx b/ui/src/pages/Install/components/SecondStep/index.tsx new file mode 100644 index 00000000..6c7125f8 --- /dev/null +++ b/ui/src/pages/Install/components/SecondStep/index.tsx @@ -0,0 +1,245 @@ +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 = ({ 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 !== 'sqlite3') { + 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 ( +
+ + {t('db_type.label')} + { + changeCallback({ + db_type: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }}> + {sqlData.map((item) => { + return ( + + ); + })} + + + {data.db_type.value !== 'sqlite3' ? ( + <> + + {t('db_username.label')} + { + changeCallback({ + db_username: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + + {data.db_username.errorMsg} + + + + + {t('db_password.label')} + { + changeCallback({ + db_password: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + + + {data.db_password.errorMsg} + + + + + {t('db_host.label')} + { + changeCallback({ + db_host: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + + {data.db_host.errorMsg} + + + + + {t('db_name.label')} + { + changeCallback({ + db_name: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + + {data.db_name.errorMsg} + + + + ) : ( + + {t('db_file.label')} + { + changeCallback({ + db_file: { + value: e.target.value, + isInvalid: false, + errorMsg: '', + }, + }); + }} + /> + + {data.db_file.errorMsg} + + + )} + +
+ + +
+ + ); +}; + +export default Index; diff --git a/ui/src/pages/Install/components/ThirdStep/index.tsx b/ui/src/pages/Install/components/ThirdStep/index.tsx new file mode 100644 index 00000000..ce79acb3 --- /dev/null +++ b/ui/src/pages/Install/components/ThirdStep/index.tsx @@ -0,0 +1,54 @@ +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; + errorMsg; + nextCallback: () => void; +} + +const Index: FC = ({ visible, errorMsg, nextCallback }) => { + const { t } = useTranslation('translation', { keyPrefix: 'install' }); + + if (!visible) return null; + return ( +
+
{t('config_yaml.title')}
+ + {errorMsg?.msg?.length > 0 ? ( + <> +
+

+ }} + /> +

+
+ + + +
{t('config_yaml.info')}
+ + ) : ( +
{t('config_yaml.label')}
+ )} + +
+ + +
+
+ ); +}; + +export default Index; diff --git a/ui/src/pages/Install/components/index.ts b/ui/src/pages/Install/components/index.ts new file mode 100644 index 00000000..ecc22539 --- /dev/null +++ b/ui/src/pages/Install/components/index.ts @@ -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 }; diff --git a/ui/src/pages/Install/index.tsx b/ui/src/pages/Install/index.tsx new file mode 100644 index 00000000..52dca0ec --- /dev/null +++ b/ui/src/pages/Install/index.tsx @@ -0,0 +1,326 @@ +/* eslint-disable prettier/prettier */ +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 { PageTitle } from '@/components'; +import { + dbCheck, + installInit, + installBaseInfo, + checkConfigFileExists, +} from '@/services'; +import { Storage } from '@/utils'; +import { CURRENT_LANG_STORAGE_KEY } from '@/common/constants'; + +import { + FirstStep, + SecondStep, + ThirdStep, + FourthStep, + Fifth, +} from './components'; + +const Index: FC = () => { + const { t } = useTranslation('translation', { keyPrefix: 'install' }); + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(true); + const [errorData, setErrorData] = useState<{ [propName: string]: any }>({ + msg: '', + }); + const [checkData, setCheckData] = useState({ + db_table_exist: false, + db_connection_success: false, + }); + + const [formData, setFormData] = useState({ + lang: { + value: 'en_US', + isInvalid: false, + errorMsg: '', + }, + db_type: { + value: 'mysql', + isInvalid: false, + errorMsg: '', + }, + db_username: { + value: 'root', + isInvalid: false, + errorMsg: '', + }, + db_password: { + value: 'root', + isInvalid: false, + errorMsg: '', + }, + db_host: { + value: 'db:3306', + isInvalid: false, + errorMsg: '', + }, + db_name: { + value: 'answer', + isInvalid: false, + errorMsg: '', + }, + db_file: { + value: '/data/answer.db', + isInvalid: false, + errorMsg: '', + }, + site_name: { + value: '', + isInvalid: false, + errorMsg: '', + }, + site_url: { + value: window.location.origin, + isInvalid: false, + errorMsg: '', + }, + contact_email: { + value: '', + isInvalid: false, + errorMsg: '', + }, + name: { + value: '', + isInvalid: false, + errorMsg: '', + }, + password: { + value: '', + isInvalid: false, + errorMsg: '', + }, + email: { + value: '', + isInvalid: false, + errorMsg: '', + }, + }); + + const handleChange = (params: FormDataType) => { + // console.log(params); + setErrorData({ + msg: '', + }); + setFormData({ ...formData, ...params }); + }; + + const handleErr = (data) => { + window.scrollTo(0, 0); + setErrorData(data); + }; + + const handleNext = async () => { + setErrorData({ + msg: '', + }); + setStep((pre) => pre + 1); + }; + + const checkInstall = () => { + 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, + }; + installInit(params) + .then(() => { + handleNext(); + }) + .catch((err) => { + handleErr(err); + }); + }; + + const submitDatabaseForm = () => { + 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, + }; + dbCheck(params) + .then(() => { + checkInstall(); + }) + .catch((err) => { + handleErr(err); + }); + }; + + const submitSiteConfig = () => { + const params = { + lang: formData.lang.value, + site_name: formData.site_name.value, + site_url: formData.site_url.value, + contact_email: formData.contact_email.value, + name: formData.name.value, + password: formData.password.value, + email: formData.email.value, + }; + installBaseInfo(params) + .then(() => { + handleNext(); + }) + .catch((err) => { + if (err.isError && err.key) { + formData[err.key].isInvalid = true; + formData[err.key].errorMsg = err.value; + setFormData({ ...formData }); + } else { + handleErr(err); + } + }); + }; + + const handleStep = () => { + if (step === 1) { + Storage.set(CURRENT_LANG_STORAGE_KEY, formData.lang.value); + handleNext(); + } + if (step === 2) { + submitDatabaseForm(); + } + if (step === 3) { + if (errorData.msg) { + checkInstall(); + } else { + handleNext(); + } + } + if (step === 4) { + submitSiteConfig(); + } + if (step > 4) { + handleNext(); + } + }; + + const handleInstallNow = (e) => { + e.preventDefault(); + if (checkData.db_table_exist) { + setStep(8); + } else { + setStep(4); + } + }; + + const configYmlCheck = () => { + checkConfigFileExists() + .then((res) => { + setCheckData({ + db_table_exist: res.db_table_exist, + db_connection_success: res.db_connection_success, + }); + if (res && res.config_file_exist) { + if (res.db_connection_success) { + setStep(6) + } else { + setStep(7); + } + } + }) + .finally(() => { + setLoading(false); + }); + }; + + useEffect(() => { + configYmlCheck(); + }, []); + + if (loading) { + return
; + } + + return ( +
+ + + +
+

{t('title')}

+ + + {errorData?.msg && ( + {errorData?.msg} + )} + + + + + + + + + + + {step === 6 && ( +
+
{t('warn_title')}
+

+ }} /> + {' '} + + You may try handleInstallNow(e)}>installing now. + +

+
+ )} + + {step === 7 && ( +
+
{t('db_failed')}
+

+ }} /> +

+
+ )} + + {step === 8 && ( +
+
{t('installed')}
+

{t('installed_description')}

+
+ )} +
+
+ + + + + ); +}; + +export default Index; diff --git a/ui/src/pages/Layout/index.tsx b/ui/src/pages/Layout/index.tsx index 69c5195d..7539c2fa 100644 --- a/ui/src/pages/Layout/index.tsx +++ b/ui/src/pages/Layout/index.tsx @@ -1,59 +1,18 @@ -import { FC, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; +import { FC, memo } from 'react'; 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, toastStore } from '@/stores'; +import { Header, Footer, Toast } from '@/components'; -import Storage from '@/utils/storage'; - -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 { siteInfo } = siteInfoStore.getState(); const closeToast = () => { toastClear(); }; - if (!isMounted) { - isMounted = true; - const lang = Storage.get('LANG'); - const user = Storage.get('userInfo'); - if (user) { - updateUser(user); - } - 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 ( @@ -65,7 +24,6 @@ const Layout: FC = () => { revalidateOnFocus: false, }}>
-
@@ -76,4 +34,4 @@ const Layout: FC = () => { ); }; -export default Layout; +export default memo(Layout); diff --git a/ui/src/pages/Maintenance/index.tsx b/ui/src/pages/Maintenance/index.tsx new file mode 100644 index 00000000..560108bf --- /dev/null +++ b/ui/src/pages/Maintenance/index.tsx @@ -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 ( +
+ + +
+ (=‘_‘=) +
+
{t('description')}
+
+
+ ); +}; + +export default Index; diff --git a/ui/src/pages/Questions/Ask/components/SearchQuestion/index.tsx b/ui/src/pages/Questions/Ask/components/SearchQuestion/index.tsx index 16388963..99a8026a 100644 --- a/ui/src/pages/Questions/Ask/components/SearchQuestion/index.tsx +++ b/ui/src/pages/Questions/Ask/components/SearchQuestion/index.tsx @@ -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'; diff --git a/ui/src/pages/Questions/Ask/index.tsx b/ui/src/pages/Questions/Ask/index.tsx index abcc86f6..3ed72f84 100644 --- a/ui/src/pages/Questions/Ask/index.tsx +++ b/ui/src/pages/Questions/Ask/index.tsx @@ -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'; @@ -281,9 +281,11 @@ const Ask = () => { {revisions.map( ({ reason, create_at, user_info }, index) => { - const date = dayjs(create_at * 1000).format( - t('long_date_with_time', { keyPrefix: 'dates' }), - ); + const date = dayjs(create_at * 1000) + .tz() + .format( + t('long_date_with_time', { keyPrefix: 'dates' }), + ); return (
+ diff --git a/ui/src/pages/Users/Register/components/SignUpForm/index.tsx b/ui/src/pages/Users/Register/components/SignUpForm/index.tsx index 2f067962..0d92c5fa 100644 --- a/ui/src/pages/Users/Register/components/SignUpForm/index.tsx +++ b/ui/src/pages/Users/Register/components/SignUpForm/index.tsx @@ -3,9 +3,8 @@ import { Form, Button, Col } from 'react-bootstrap'; import { Link } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; -import { register } from '@answer/api'; -import type { FormDataType } from '@answer/common/interface'; - +import type { FormDataType } from '@/common/interface'; +import { register } from '@/services'; import userStore from '@/stores/userInfo'; interface Props { diff --git a/ui/src/pages/Users/Register/index.tsx b/ui/src/pages/Users/Register/index.tsx index c50c353c..811de77d 100644 --- a/ui/src/pages/Users/Register/index.tsx +++ b/ui/src/pages/Users/Register/index.tsx @@ -1,9 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Container } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { PageTitle, Unactivate } from '@answer/components'; -import { isLogin } from '@answer/utils'; +import { PageTitle, Unactivate } from '@/components'; import SignUpForm from './components/SignUpForm'; @@ -15,10 +14,6 @@ const Index: React.FC = () => { setShowForm((bol) => !bol); }; - useEffect(() => { - isLogin(); - }, []); - return (

{t('page_title')}

diff --git a/ui/src/pages/Users/Settings/Account/components/ModifyEmail/index.tsx b/ui/src/pages/Users/Settings/Account/components/ModifyEmail/index.tsx index 84182a53..8bfa6dfa 100644 --- a/ui/src/pages/Users/Settings/Account/components/ModifyEmail/index.tsx +++ b/ui/src/pages/Users/Settings/Account/components/ModifyEmail/index.tsx @@ -2,9 +2,9 @@ import React, { FC, FormEvent, useEffect, useState } from 'react'; import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import type * as Type from '@answer/common/interface'; -import { getUserInfo, changeEmail } from '@answer/api'; -import { useToast } from '@answer/hooks'; +import type * as Type from '@/common/interface'; +import { useToast } from '@/hooks'; +import { getLoggedUserInfo, changeEmail } from '@/services'; const reg = /(?<=.{2}).+(?=@)/gi; @@ -23,7 +23,7 @@ const Index: FC = () => { const [userInfo, setUserInfo] = useState(); const toast = useToast(); useEffect(() => { - getUserInfo().then((resp) => { + getLoggedUserInfo().then((resp) => { setUserInfo(resp); }); }, []); diff --git a/ui/src/pages/Users/Settings/Account/components/ModifyPass/index.tsx b/ui/src/pages/Users/Settings/Account/components/ModifyPass/index.tsx index 23265763..56d8f17b 100644 --- a/ui/src/pages/Users/Settings/Account/components/ModifyPass/index.tsx +++ b/ui/src/pages/Users/Settings/Account/components/ModifyPass/index.tsx @@ -2,9 +2,9 @@ import React, { FC, FormEvent, useState } from 'react'; import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { modifyPassword } from '@answer/api'; -import { useToast } from '@answer/hooks'; -import type { FormDataType } from '@answer/common/interface'; +import { useToast } from '@/hooks'; +import type { FormDataType } from '@/common/interface'; +import { modifyPassword } from '@/services'; const Index: FC = () => { const { t } = useTranslation('translation', { diff --git a/ui/src/pages/Users/Settings/Interface/index.tsx b/ui/src/pages/Users/Settings/Interface/index.tsx index ca7ce38d..7d60dea8 100644 --- a/ui/src/pages/Users/Settings/Interface/index.tsx +++ b/ui/src/pages/Users/Settings/Interface/index.tsx @@ -2,65 +2,55 @@ import React, { useEffect, useState, FormEvent } from 'react'; import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import dayjs from 'dayjs'; -import en from 'dayjs/locale/en'; -import zh from 'dayjs/locale/zh-cn'; - -import { languages } from '@answer/api'; -import type { LangsType, FormDataType } from '@answer/common/interface'; -import { useToast } from '@answer/hooks'; - -import Storage from '@/utils/storage'; +import type { LangsType, FormDataType } from '@/common/interface'; +import { useToast } from '@/hooks'; +import { updateUserInterface } from '@/services'; +import { localize } from '@/utils'; +import { loggedUserInfoStore } from '@/stores'; const Index = () => { - const { t, i18n } = useTranslation('translation', { + const { t } = useTranslation('translation', { keyPrefix: 'settings.interface', }); + const loggedUserInfo = loggedUserInfoStore.getState().user; const toast = useToast(); const [langs, setLangs] = useState(); const [formData, setFormData] = useState({ lang: { - value: true, + value: loggedUserInfo.language, isInvalid: false, errorMsg: '', }, }); const getLangs = async () => { - const res: LangsType[] = await languages(); + const res: LangsType[] = await localize.loadLanguageOptions(); setLangs(res); }; const handleSubmit = (event: FormEvent) => { event.preventDefault(); - - Storage.set('LANG', formData.lang.value); - dayjs.locale(formData.lang.value === 'en_US' ? en : zh); - i18n.changeLanguage(formData.lang.value); - toast.onShow({ - msg: t('update', { keyPrefix: 'toast' }), - variant: 'success', + const lang = formData.lang.value; + updateUserInterface(lang).then(() => { + loggedUserInfoStore.getState().update({ + ...loggedUserInfo, + language: lang, + }); + localize.setupAppLanguage(); + toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); }); }; useEffect(() => { getLangs(); - const lang = Storage.get('LANG'); - if (lang) { - setFormData({ - lang: { - value: lang, - isInvalid: false, - errorMsg: '', - }, - }); - } }, []); return (
{t('lang.label')} - { }}> {langs?.map((item) => { return ( - ); diff --git a/ui/src/pages/Users/Settings/Notification/index.tsx b/ui/src/pages/Users/Settings/Notification/index.tsx index 47142cde..e7310145 100644 --- a/ui/src/pages/Users/Settings/Notification/index.tsx +++ b/ui/src/pages/Users/Settings/Notification/index.tsx @@ -2,9 +2,9 @@ import React, { useState, FormEvent, useEffect } from 'react'; import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import type { FormDataType } from '@answer/common/interface'; -import { setNotice, getUserInfo } from '@answer/api'; -import { useToast } from '@answer/hooks'; +import type { FormDataType } from '@/common/interface'; +import { useToast } from '@/hooks'; +import { setNotice, getLoggedUserInfo } from '@/services'; const Index = () => { const toast = useToast(); @@ -20,7 +20,7 @@ const Index = () => { }); const getProfile = () => { - getUserInfo().then((res) => { + getLoggedUserInfo().then((res) => { setFormData({ notice_switch: { value: res.notice_status === 1, diff --git a/ui/src/pages/Users/Settings/Profile/index.tsx b/ui/src/pages/Users/Settings/Profile/index.tsx index 84478fd1..500c2ef1 100644 --- a/ui/src/pages/Users/Settings/Profile/index.tsx +++ b/ui/src/pages/Users/Settings/Profile/index.tsx @@ -5,18 +5,18 @@ import { Trans, useTranslation } from 'react-i18next'; import { marked } from 'marked'; import MD5 from 'md5'; -import { modifyUserInfo, uploadAvatar, getUserInfo } from '@answer/api'; -import type { FormDataType } from '@answer/common/interface'; -import { UploadImg, Avatar } from '@answer/components'; -import { userInfoStore } from '@answer/stores'; -import { useToast } from '@answer/hooks'; +import type { FormDataType } from '@/common/interface'; +import { UploadImg, Avatar } from '@/components'; +import { loggedUserInfoStore } from '@/stores'; +import { useToast } from '@/hooks'; +import { modifyUserInfo, uploadAvatar, getLoggedUserInfo } from '@/services'; const Index: React.FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'settings.profile', }); const toast = useToast(); - const { user, update } = userInfoStore(); + const { user, update } = loggedUserInfoStore(); const [mailHash, setMailHash] = useState(''); const [count, setCount] = useState(0); @@ -188,7 +188,7 @@ const Index: React.FC = () => { }; const getProfile = () => { - getUserInfo().then((res) => { + getLoggedUserInfo().then((res) => { formData.display_name.value = res.display_name; formData.username.value = res.username; formData.bio.value = res.bio; diff --git a/ui/src/pages/Users/Settings/index.tsx b/ui/src/pages/Users/Settings/index.tsx index 45f3080c..dc591323 100644 --- a/ui/src/pages/Users/Settings/index.tsx +++ b/ui/src/pages/Users/Settings/index.tsx @@ -3,13 +3,12 @@ import { Container, Row, Col } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { Outlet } from 'react-router-dom'; -import { getUserInfo } from '@answer/api'; -import type { FormDataType } from '@answer/common/interface'; +import type { FormDataType } from '@/common/interface'; +import { getLoggedUserInfo } from '@/services'; +import { PageTitle } from '@/components'; import Nav from './components/Nav'; -import { PageTitle } from '@/components'; - const Index: React.FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'settings.profile', @@ -43,7 +42,7 @@ const Index: React.FC = () => { }, }); const getProfile = () => { - getUserInfo().then((res) => { + getLoggedUserInfo().then((res) => { formData.display_name.value = res.display_name; formData.bio.value = res.bio; formData.avatar.value = res.avatar; diff --git a/ui/src/pages/Users/Suspended/index.tsx b/ui/src/pages/Users/Suspended/index.tsx index 293603d0..403595a9 100644 --- a/ui/src/pages/Users/Suspended/index.tsx +++ b/ui/src/pages/Users/Suspended/index.tsx @@ -1,12 +1,11 @@ import { useTranslation } from 'react-i18next'; -import { userInfoStore } from '@answer/stores'; - +import { loggedUserInfoStore } from '@/stores'; import { PageTitle } from '@/components'; const Suspended = () => { const { t } = useTranslation('translation', { keyPrefix: 'suspended' }); - const userInfo = userInfoStore((state) => state.user); + const userInfo = loggedUserInfoStore((state) => state.user); if (userInfo.status !== 'forbidden') { window.location.replace('/'); diff --git a/ui/src/react-app-env.d.ts b/ui/src/react-app-env.d.ts index 6431bc5f..c40b86b3 100644 --- a/ui/src/react-app-env.d.ts +++ b/ui/src/react-app-env.d.ts @@ -1 +1,2 @@ /// +declare module '*.yaml'; diff --git a/ui/src/router/alias.ts b/ui/src/router/alias.ts new file mode 100644 index 00000000..f6959ed3 --- /dev/null +++ b/ui/src/router/alias.ts @@ -0,0 +1,8 @@ +export const RouteAlias = { + home: '/', + login: '/users/login', + register: '/users/register', + activation: '/users/login?status=inactive', + activationFailed: '/users/account-activation/failed', + suspended: '/users/account-suspended', +}; diff --git a/ui/src/router/index.tsx b/ui/src/router/index.tsx index 99d44723..e5aa2797 100644 --- a/ui/src/router/index.tsx +++ b/ui/src/router/index.tsx @@ -1,57 +1,56 @@ import React, { Suspense, lazy } from 'react'; -import { RouteObject, createBrowserRouter } from 'react-router-dom'; +import { RouteObject, createBrowserRouter, redirect } from 'react-router-dom'; -import Layout from '@answer/pages/Layout'; - -import routeConfig, { RouteNode } from '@/router/route-config'; -import RouteRules from '@/router/route-rules'; +import Layout from '@/pages/Layout'; +import ErrorBoundary from '@/pages/50X'; +import baseRoutes, { RouteNode } from '@/router/routes'; +import { floppyNavigation } from '@/utils'; const routes: RouteObject[] = []; -const routeGen = (routeNodes: RouteNode[], root: RouteObject[]) => { +const routeWrapper = (routeNodes: RouteNode[], root: RouteObject[]) => { routeNodes.forEach((rn) => { if (rn.path === '/') { rn.element = ; + rn.errorElement = ; } else { /** * cannot use a fully dynamic import statement * ref: https://webpack.js.org/api/module-methods/#import-1 */ rn.page = rn.page.replace('pages/', ''); - const Control = lazy(() => import(`@/pages/${rn.page}`)); + const Ctrl = lazy(() => import(`@/pages/${rn.page}`)); rn.element = ( - + ); } root.push(rn); - if (Array.isArray(rn.rules)) { - const ruleFunc: Function[] = []; - if (typeof rn.loader === 'function') { - ruleFunc.push(rn.loader); - } - rn.rules.forEach((ruleKey) => { - const func = RouteRules[ruleKey]; - if (typeof func === 'function') { - ruleFunc.push(func); + if (rn.guard) { + const refLoader = rn.loader; + const refGuard = rn.guard; + rn.loader = async (args) => { + const gr = await refGuard(); + if (gr?.redirect && floppyNavigation.differentCurrent(gr.redirect)) { + return redirect(gr.redirect); } - }); - rn.loader = ({ params }) => { - ruleFunc.forEach((func) => { - func(params); - }); + + let lr; + if (typeof refLoader === 'function') { + lr = await refLoader(args); + } + return lr; }; } const children = Array.isArray(rn.children) ? rn.children : null; if (children) { rn.children = []; - routeGen(children, rn.children); + routeWrapper(children, rn.children); } }); }; -routeGen(routeConfig, routes); +routeWrapper(baseRoutes, routes); -const router = createBrowserRouter(routes); -export default router; +export { routes, createBrowserRouter }; diff --git a/ui/src/router/route-rules.ts b/ui/src/router/route-rules.ts deleted file mode 100644 index e7c2b83c..00000000 --- a/ui/src/router/route-rules.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { isLogin } from '@answer/utils'; - -const RouteRules = { - isLoginAndNormal: () => { - return isLogin(true); - }, -}; - -export default RouteRules; diff --git a/ui/src/router/route-config.ts b/ui/src/router/routes.ts similarity index 68% rename from ui/src/router/route-config.ts rename to ui/src/router/routes.ts index 2e46ffbe..a723284c 100644 --- a/ui/src/router/route-config.ts +++ b/ui/src/router/routes.ts @@ -1,14 +1,28 @@ import { RouteObject } from 'react-router-dom'; +import { guard } from '@/utils'; +import type { TGuardResult } from '@/utils/guard'; + export interface RouteNode extends RouteObject { page: string; children?: RouteNode[]; - rules?: string[]; + /** + * a method to auto guard route before route enter + * if the `ok` field in guard returned `TGuardResult` is true, + * it means the guard passed then enter the route. + * if guard returned the `TGuardResult` has `redirect` field, + * then auto redirect route to the `redirect` target. + */ + guard?: () => Promise; } -const routeConfig: RouteNode[] = [ + +const routes: RouteNode[] = [ { path: '/', page: 'pages/Layout', + guard: async () => { + return guard.notForbidden(); + }, children: [ // question and answer { @@ -31,12 +45,16 @@ const routeConfig: RouteNode[] = [ { path: 'questions/ask', page: 'pages/Questions/Ask', - rules: ['isLoginAndNormal'], + guard: async () => { + return guard.activated(); + }, }, { path: 'posts/:qid/edit', page: 'pages/Questions/Ask', - rules: ['isLoginAndNormal'], + guard: async () => { + return guard.activated(); + }, }, { path: 'posts/:qid/:aid/edit', @@ -62,6 +80,9 @@ const routeConfig: RouteNode[] = [ { path: 'tags/:tagId/edit', page: 'pages/Tags/Edit', + guard: async () => { + return guard.activated(); + }, }, // users { @@ -75,6 +96,9 @@ const routeConfig: RouteNode[] = [ { path: 'users/settings', page: 'pages/Users/Settings', + guard: async () => { + return guard.logged(); + }, children: [ { index: true, @@ -105,47 +129,87 @@ const routeConfig: RouteNode[] = [ { path: 'users/login', page: 'pages/Users/Login', + guard: async () => { + const notLogged = guard.notLogged(); + if (notLogged.ok) { + return notLogged; + } + return guard.notActivated(); + }, }, { path: 'users/register', page: 'pages/Users/Register', + guard: async () => { + return guard.notLogged(); + }, }, { path: 'users/account-recovery', page: 'pages/Users/AccountForgot', + guard: async () => { + return guard.activated(); + }, }, { path: 'users/change-email', page: 'pages/Users/ChangeEmail', + guard: async () => { + return guard.notActivated(); + }, }, { path: 'users/password-reset', page: 'pages/Users/PasswordReset', + guard: async () => { + return guard.activated(); + }, }, { path: 'users/account-activation', page: 'pages/Users/ActiveEmail', + guard: async () => { + const notActivated = guard.notActivated(); + if (notActivated.ok) { + return notActivated; + } + return guard.notLogged(); + }, }, { path: 'users/account-activation/success', page: 'pages/Users/ActivationResult', + guard: async () => { + return guard.activated(); + }, }, { path: '/users/account-activation/failed', page: 'pages/Users/ActivationResult', + guard: async () => { + return guard.notActivated(); + }, }, { path: '/users/confirm-new-email', page: 'pages/Users/ConfirmNewEmail', + // TODO: guard this }, { path: '/users/account-suspended', page: 'pages/Users/Suspended', + guard: async () => { + return guard.forbidden(); + }, }, // for admin { path: 'admin', page: 'pages/Admin', + guard: async () => { + await guard.pullLoggedUser(true); + return guard.admin(); + }, children: [ { index: true, @@ -199,5 +263,13 @@ const routeConfig: RouteNode[] = [ }, ], }, + { + path: '/install', + page: 'pages/Install', + }, + { + path: '/maintenance', + page: 'pages/Maintenance', + }, ]; -export default routeConfig; +export default routes; diff --git a/ui/src/services/admin/answer.ts b/ui/src/services/admin/answer.ts index 6fd0fbb6..08ad8e2f 100644 --- a/ui/src/services/admin/answer.ts +++ b/ui/src/services/admin/answer.ts @@ -1,10 +1,12 @@ import useSWR from 'swr'; import qs from 'qs'; -import request from '@answer/utils/request'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; -export const useAnswerSearch = (params: Type.AdminContentsReq) => { +export const useAnswerSearch = ( + params: Type.AdminContentsReq & { question_id?: string }, +) => { const apiUrl = `/answer/admin/api/answer/page?${qs.stringify(params)}`; const { data, error, mutate } = useSWR( [apiUrl], diff --git a/ui/src/services/admin/flag.ts b/ui/src/services/admin/flag.ts index 710cb447..64ea59f7 100644 --- a/ui/src/services/admin/flag.ts +++ b/ui/src/services/admin/flag.ts @@ -1,8 +1,8 @@ import useSWR from 'swr'; import qs from 'qs'; -import request from '@answer/utils/request'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; export const putReport = (params) => { return request.instance.put('/answer/admin/api/report', params); diff --git a/ui/src/services/admin/question.ts b/ui/src/services/admin/question.ts index a6308bf6..9e8d726a 100644 --- a/ui/src/services/admin/question.ts +++ b/ui/src/services/admin/question.ts @@ -1,8 +1,8 @@ import qs from 'qs'; import useSWR from 'swr'; -import request from '@answer/utils/request'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; export const changeUserStatus = (params) => { return request.put('/answer/admin/api/user/status', params); diff --git a/ui/src/services/admin/settings.ts b/ui/src/services/admin/settings.ts index e1f486e7..6bae3576 100644 --- a/ui/src/services/admin/settings.ts +++ b/ui/src/services/admin/settings.ts @@ -1,7 +1,7 @@ import useSWR from 'swr'; -import request from '@answer/utils/request'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; export const useGeneralSetting = () => { const apiUrl = `/answer/admin/api/siteinfo/general`; @@ -70,3 +70,21 @@ export const updateSmtpSetting = (params: Type.AdminSettingsSmtp) => { const apiUrl = `/answer/admin/api/setting/smtp`; return request.put(apiUrl, params); }; + +export const useDashBoard = () => { + const apiUrl = `/answer/admin/api/dashboard`; + const { data, error } = useSWR( + [apiUrl], + request.instance.get, + ); + return { + data, + isLoading: !data && !error, + error, + }; +}; + +export const getAdminLanguageOptions = () => { + const apiUrl = `/answer/admin/api/language/options`; + return request.get(apiUrl); +}; diff --git a/ui/src/services/client/activity.ts b/ui/src/services/client/activity.ts index 7b7b539c..6f1a4fdb 100644 --- a/ui/src/services/client/activity.ts +++ b/ui/src/services/client/activity.ts @@ -1,7 +1,7 @@ import useSWR from 'swr'; -import request from '@answer/utils/request'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; export const useFollow = (params?: Type.FollowParams) => { const apiUrl = '/answer/api/v1/follow'; diff --git a/ui/src/services/client/index.ts b/ui/src/services/client/index.ts index dbea39bf..17cfb7e0 100644 --- a/ui/src/services/client/index.ts +++ b/ui/src/services/client/index.ts @@ -1,7 +1,7 @@ export * from './activity'; export * from './personal'; -export * from './user'; export * from './notification'; export * from './question'; export * from './search'; export * from './tag'; +export * from './settings'; diff --git a/ui/src/services/client/notification.ts b/ui/src/services/client/notification.ts index 18b73343..6d34022b 100644 --- a/ui/src/services/client/notification.ts +++ b/ui/src/services/client/notification.ts @@ -1,9 +1,9 @@ import useSWR from 'swr'; import qs from 'qs'; -import request from '@answer/utils/request'; -import { isLogin } from '@answer/utils'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; +import { tryLoggedAndActicevated } from '@/utils/guard'; export const useQueryNotifications = (params) => { const apiUrl = `/answer/api/v1/notification/page?${qs.stringify(params, { @@ -33,7 +33,7 @@ export const useQueryNotificationStatus = () => { const apiUrl = '/answer/api/v1/notification/status'; return useSWR<{ inbox: number; achievement: number }>( - isLogin() ? apiUrl : null, + tryLoggedAndActicevated().ok ? apiUrl : null, request.instance.get, { refreshInterval: 3000, diff --git a/ui/src/services/client/personal.ts b/ui/src/services/client/personal.ts index b84e260f..6b61aaba 100644 --- a/ui/src/services/client/personal.ts +++ b/ui/src/services/client/personal.ts @@ -1,8 +1,8 @@ import useSWR from 'swr'; import qs from 'qs'; -import request from '@answer/utils/request'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; export const usePersonalInfoByName = (username: string) => { const apiUrl = '/answer/api/v1/personal/user/info'; diff --git a/ui/src/services/client/question.ts b/ui/src/services/client/question.ts index c35fe544..c418f26c 100644 --- a/ui/src/services/client/question.ts +++ b/ui/src/services/client/question.ts @@ -1,8 +1,8 @@ import useSWR from 'swr'; import qs from 'qs'; -import request from '@answer/utils/request'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; export const useQuestionList = (params: Type.QueryQuestionsReq) => { const apiUrl = `/answer/api/v1/question/page?${qs.stringify(params)}`; diff --git a/ui/src/services/client/search.ts b/ui/src/services/client/search.ts index f5fe86fe..8d380294 100644 --- a/ui/src/services/client/search.ts +++ b/ui/src/services/client/search.ts @@ -1,8 +1,8 @@ import useSWR from 'swr'; import qs from 'qs'; -import request from '@answer/utils/request'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; export const useSearch = (params?: Type.SearchParams) => { const apiUrl = '/answer/api/v1/search'; diff --git a/ui/src/services/client/settings.ts b/ui/src/services/client/settings.ts new file mode 100644 index 00000000..27fbe1a9 --- /dev/null +++ b/ui/src/services/client/settings.ts @@ -0,0 +1,16 @@ +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; + +export const getLanguageConfig = () => { + return request.get('/answer/api/v1/language/config'); +}; + +export const getLanguageOptions = () => { + return request.get('/answer/api/v1/language/options'); +}; + +export const updateUserInterface = (lang: string) => { + return request.put('/answer/api/v1/user/interface', { + language: lang, + }); +}; diff --git a/ui/src/services/client/tag.ts b/ui/src/services/client/tag.ts index 87e46743..b1ec3a9a 100644 --- a/ui/src/services/client/tag.ts +++ b/ui/src/services/client/tag.ts @@ -1,8 +1,8 @@ import useSWR from 'swr'; -import request from '@answer/utils/request'; -import { isLogin } from '@answer/utils'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; +import { tryLoggedAndActicevated } from '@/utils/guard'; export const deleteTag = (id) => { return request.delete('/answer/api/v1/tag', { @@ -24,7 +24,7 @@ export const saveSynonymsTags = (params) => { export const useFollowingTags = () => { let apiUrl = ''; - if (isLogin()) { + if (tryLoggedAndActicevated().ok) { apiUrl = '/answer/api/v1/tags/following'; } const { data, error, mutate } = useSWR(apiUrl, request.instance.get); diff --git a/ui/src/services/client/user.ts b/ui/src/services/client/user.ts deleted file mode 100644 index abe9ee3a..00000000 --- a/ui/src/services/client/user.ts +++ /dev/null @@ -1,17 +0,0 @@ -import useSWR from 'swr'; - -import request from '@answer/utils/request'; - -export const useCheckUserStatus = () => { - const apiUrl = '/answer/api/v1/user/status'; - const hasToken = localStorage.getItem('token'); - const { data, error } = useSWR<{ status: string }, Error>( - hasToken ? apiUrl : null, - request.instance.get, - ); - return { - data, - isLoading: !data && !error, - error, - }; -}; diff --git a/ui/src/services/common.ts b/ui/src/services/common.ts index c98c532c..1ddc7f18 100644 --- a/ui/src/services/common.ts +++ b/ui/src/services/common.ts @@ -1,8 +1,8 @@ import qs from 'qs'; import useSWR from 'swr'; -import request from '@answer/utils/request'; -import type * as Type from '@answer/common/interface'; +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; export const uploadImage = (file) => { const form = new FormData(); @@ -115,7 +115,7 @@ export const resendEmail = (params?: Type.ImgCodeReq) => { * @description get login userinfo * @returns {UserInfo} */ -export const getUserInfo = () => { +export const getLoggedUserInfo = () => { return request.get('/answer/api/v1/user/info'); }; @@ -163,14 +163,6 @@ export const questionDetail = (id: string) => { ); }; -export const langConfig = () => { - return request.get('/answer/api/v1/language/config'); -}; - -export const languages = () => { - return request.get('/answer/api/v1/language/options'); -}; - export const getAnswers = (params: Type.AnswersReq) => { const apiUrl = `/answer/api/v1/answer/page?${qs.stringify(params)}`; return request.get>(apiUrl); @@ -253,16 +245,6 @@ export const changeEmailVerify = (params: { code: string }) => { return request.put('/answer/api/v1/user/email', params); }; -export const useSiteSettings = () => { - const apiUrl = `/answer/api/v1/siteinfo`; - const { data, error } = useSWR( - [apiUrl], - request.instance.get, - ); - - return { - data, - isLoading: !data && !error, - error, - }; +export const getAppSettings = () => { + return request.get('/answer/api/v1/siteinfo'); }; diff --git a/ui/src/services/api.ts b/ui/src/services/index.ts similarity index 74% rename from ui/src/services/api.ts rename to ui/src/services/index.ts index a6923bcf..a3e936ec 100644 --- a/ui/src/services/api.ts +++ b/ui/src/services/index.ts @@ -1,3 +1,4 @@ export * from './admin'; export * from './common'; export * from './client'; +export * from './install'; diff --git a/ui/src/services/install/index.ts b/ui/src/services/install/index.ts new file mode 100644 index 00000000..a2026a7e --- /dev/null +++ b/ui/src/services/install/index.ts @@ -0,0 +1,21 @@ +import request from '@/utils/request'; + +export const checkConfigFileExists = () => { + return request.post('/installation/config-file/check'); +}; + +export const dbCheck = (params) => { + return request.post('/installation/db/check', params); +}; + +export const installInit = (params) => { + return request.post('/installation/init', params); +}; + +export const installBaseInfo = (params) => { + return request.post('/installation/base-info', params); +}; + +export const getInstallLangOptions = () => { + return request.get('/installation/language/options'); +}; diff --git a/ui/src/stores/index.ts b/ui/src/stores/index.ts index 0962911e..6bc6377e 100644 --- a/ui/src/stores/index.ts +++ b/ui/src/stores/index.ts @@ -1,12 +1,12 @@ import toastStore from './toast'; -import userInfoStore from './userInfo'; +import loggedUserInfoStore from './userInfo'; import globalStore from './global'; import siteInfoStore from './siteInfo'; import interfaceStore from './interface'; export { toastStore, - userInfoStore, + loggedUserInfoStore, globalStore, siteInfoStore, interfaceStore, diff --git a/ui/src/stores/interface.ts b/ui/src/stores/interface.ts index ea964efb..e92e0f8a 100644 --- a/ui/src/stores/interface.ts +++ b/ui/src/stores/interface.ts @@ -1,21 +1,19 @@ import create from 'zustand'; -interface updateParams { - logo: string; - theme: string; - language: string; -} +import { AdminSettingsInterface } from '@/common/interface'; +import { DEFAULT_LANG } from '@/common/constants'; interface InterfaceType { - interface: updateParams; - update: (params: updateParams) => void; + interface: AdminSettingsInterface; + update: (params: AdminSettingsInterface) => void; } const interfaceSetting = create((set) => ({ interface: { logo: '', theme: '', - language: '', + language: DEFAULT_LANG, + time_zone: '', }, update: (params) => set(() => { diff --git a/ui/src/stores/siteInfo.ts b/ui/src/stores/siteInfo.ts index f5a058dc..14f3a273 100644 --- a/ui/src/stores/siteInfo.ts +++ b/ui/src/stores/siteInfo.ts @@ -1,14 +1,10 @@ import create from 'zustand'; -interface updateParams { - name: string; - description: string; - short_description: string; -} +import { AdminSettingsGeneral } from '@/common/interface'; interface SiteInfoType { - siteInfo: updateParams; - update: (params: updateParams) => void; + siteInfo: AdminSettingsGeneral; + update: (params: AdminSettingsGeneral) => void; } const siteInfo = create((set) => ({ @@ -16,6 +12,8 @@ const siteInfo = create((set) => ({ name: '', description: '', short_description: '', + site_url: '', + contact_email: '', }, update: (params) => set(() => { diff --git a/ui/src/stores/userInfo.ts b/ui/src/stores/userInfo.ts index bbfb640e..e82a592b 100644 --- a/ui/src/stores/userInfo.ts +++ b/ui/src/stores/userInfo.ts @@ -1,7 +1,11 @@ import create from 'zustand'; -import type { UserInfoRes } from '@answer/common/interface'; -import Storage from '@answer/utils/storage'; +import type { UserInfoRes } from '@/common/interface'; +import Storage from '@/utils/storage'; +import { + LOGGED_USER_STORAGE_KEY, + LOGGED_TOKEN_STORAGE_KEY, +} from '@/common/constants'; interface UserInfoStore { user: UserInfoRes; @@ -10,6 +14,7 @@ interface UserInfoStore { } const initUser: UserInfoRes = { + access_token: '', username: '', avatar: '', rank: 0, @@ -19,23 +24,28 @@ const initUser: UserInfoRes = { location: '', website: '', status: '', - mail_status: 0, + mail_status: 1, + language: 'Default', }; -const userInfoStore = create((set) => ({ +const loggedUserInfoStore = create((set) => ({ user: initUser, - update: (params) => + update: (params) => { + if (!params.language) { + params.language = 'Default'; + } set(() => { - Storage.set('token', params.access_token); - Storage.set('userInfo', params); + Storage.set(LOGGED_TOKEN_STORAGE_KEY, params.access_token); + Storage.set(LOGGED_USER_STORAGE_KEY, params); return { user: params }; - }), + }); + }, clear: () => set(() => { - // Storage.remove('token'); - Storage.remove('userInfo'); + Storage.remove(LOGGED_TOKEN_STORAGE_KEY); + Storage.remove(LOGGED_USER_STORAGE_KEY); return { user: initUser }; }), })); -export default userInfoStore; +export default loggedUserInfoStore; diff --git a/ui/src/utils/common.ts b/ui/src/utils/common.ts new file mode 100644 index 00000000..2f846382 --- /dev/null +++ b/ui/src/utils/common.ts @@ -0,0 +1,114 @@ +import i18next from 'i18next'; + +function getQueryString(name: string): string { + const reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`); + const r = window.location.search.substr(1).match(reg); + if (r != null) return unescape(r[2]); + return ''; +} + +function thousandthDivision(num) { + const reg = /\d{1,3}(?=(\d{3})+$)/g; + return `${num}`.replace(reg, '$&,'); +} + +function formatCount($num: number): string { + let res = String($num); + if (!Number.isFinite($num)) { + res = '0'; + } else if ($num < 10000) { + res = thousandthDivision($num); + } else if ($num < 1000000) { + res = `${Math.round($num / 100) / 10}k`; + } else if ($num >= 1000000) { + res = `${Math.round($num / 100000) / 10}m`; + } + return res; +} + +function scrollTop(element) { + if (!element) { + return; + } + const offset = 120; + const bodyRect = document.body.getBoundingClientRect().top; + const elementRect = element.getBoundingClientRect().top; + const elementPosition = elementRect - bodyRect; + const offsetPosition = elementPosition - offset; + + window.scrollTo({ + top: offsetPosition, + }); +} + +/** + * Extract user info from markdown + * @param markdown string + * @returns Array<{displayName: string, userName: string}> + */ +function matchedUsers(markdown) { + const globalReg = /\B@([\w|]+)/g; + const reg = /\B@([\w\\_\\.]+)/; + + const users = markdown.match(globalReg); + if (!users) { + return []; + } + return users.map((user) => { + const matched = user.match(reg); + return { + userName: matched[1], + }; + }); +} + +/** + * Identify user information from markdown + * @param markdown string + * @returns string + */ +function parseUserInfo(markdown) { + const globalReg = /\B@([\w\\_\\.\\-]+)/g; + return markdown.replace(globalReg, '[@$1](/u/$1)'); +} + +function formatUptime(value) { + const t = i18next.t.bind(i18next); + const second = parseInt(value, 10); + + if (second > 60 * 60 && second < 60 * 60 * 24) { + return `${Math.floor(second / 3600)} ${t('dates.hour')}`; + } + if (second > 60 * 60 * 24) { + return `${Math.floor(second / 3600 / 24)} ${t('dates.day')}`; + } + + return `< 1 ${t('dates.hour')}`; +} + +function escapeRemove(str) { + if (!str || typeof str !== 'string') return str; + const arrEntities = { + lt: '<', + gt: '>', + nbsp: ' ', + amp: '&', + quot: '"', + '#39': "'", + }; + + return str.replace(/&(lt|gt|nbsp|amp|quot|#39);/gi, function (all, t) { + return arrEntities[t]; + }); +} + +export { + getQueryString, + thousandthDivision, + formatCount, + scrollTop, + matchedUsers, + parseUserInfo, + formatUptime, + escapeRemove, +}; diff --git a/ui/src/utils/floppyNavigation.ts b/ui/src/utils/floppyNavigation.ts new file mode 100644 index 00000000..68e6f8a5 --- /dev/null +++ b/ui/src/utils/floppyNavigation.ts @@ -0,0 +1,41 @@ +import { RouteAlias } from '@/router/alias'; +import Storage from '@/utils/storage'; +import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants'; + +const differentCurrent = (target: string, base?: string) => { + base ||= window.location.origin; + const targetUrl = new URL(target, base); + return targetUrl.toString() !== window.location.href; +}; + +/** + * only navigate if not same as current url + * @param pathname + * @param callback + */ +const navigate = (pathname: string, callback: Function) => { + if (differentCurrent(pathname)) { + callback(); + } +}; + +/** + * auto navigate to login page with redirect info + */ +const navigateToLogin = () => { + const { pathname } = window.location; + if (pathname !== RouteAlias.login && pathname !== RouteAlias.register) { + const loc = window.location; + const redirectUrl = loc.href.replace(loc.origin, ''); + Storage.set(REDIRECT_PATH_STORAGE_KEY, redirectUrl); + } + navigate(RouteAlias.login, () => { + window.location.replace(RouteAlias.login); + }); +}; + +export const floppyNavigation = { + differentCurrent, + navigate, + navigateToLogin, +}; diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts new file mode 100644 index 00000000..4fb21496 --- /dev/null +++ b/ui/src/utils/guard.ts @@ -0,0 +1,224 @@ +import { getLoggedUserInfo, getAppSettings } from '@/services'; +import { loggedUserInfoStore, siteInfoStore, interfaceStore } from '@/stores'; +import { RouteAlias } from '@/router/alias'; +import Storage from '@/utils/storage'; +import { LOGGED_USER_STORAGE_KEY } from '@/common/constants'; +import { setupAppLanguage, setupAppTimeZone } from '@/utils/localize'; + +import { floppyNavigation } from './floppyNavigation'; + +type TLoginState = { + isLogged: boolean; + isNotActivated: boolean; + isActivated: boolean; + isForbidden: boolean; + isNormal: boolean; + isAdmin: boolean; +}; + +export type TGuardResult = { + ok: boolean; + redirect?: string; +}; + +export const deriveLoginState = (): TLoginState => { + const ls: TLoginState = { + isLogged: false, + isNotActivated: false, + isActivated: false, + isForbidden: false, + isNormal: false, + isAdmin: false, + }; + const { user } = loggedUserInfoStore.getState(); + if (user.access_token) { + ls.isLogged = true; + } + if (ls.isLogged && user.mail_status === 1) { + ls.isActivated = true; + } + if (ls.isLogged && user.mail_status === 2) { + ls.isNotActivated = true; + } + if (ls.isLogged && user.status === 'forbidden') { + ls.isForbidden = true; + } + if (ls.isActivated && !ls.isForbidden) { + ls.isNormal = true; + } + if (ls.isNormal && user.is_admin === true) { + ls.isAdmin = true; + } + + return ls; +}; + +let pullLock = false; +let dedupeTimestamp = 0; +export const pullLoggedUser = async (forceRePull = false) => { + // only pull once if not force re-pull + if (pullLock && !forceRePull) { + return; + } + // dedupe pull requests in this time span in 10 seconds + if (Date.now() - dedupeTimestamp < 1000 * 10) { + return; + } + dedupeTimestamp = Date.now(); + const loggedUserInfo = await getLoggedUserInfo().catch((ex) => { + dedupeTimestamp = 0; + if (!deriveLoginState().isLogged) { + // load fallback userInfo from local storage + const storageLoggedUserInfo = Storage.get(LOGGED_USER_STORAGE_KEY); + if (storageLoggedUserInfo) { + loggedUserInfoStore.getState().update(storageLoggedUserInfo); + } + } + console.error(ex); + }); + if (loggedUserInfo) { + pullLock = true; + loggedUserInfoStore.getState().update(loggedUserInfo); + } +}; + +export const logged = () => { + const gr: TGuardResult = { ok: true }; + const us = deriveLoginState(); + if (!us.isLogged) { + gr.ok = false; + gr.redirect = RouteAlias.login; + } + return gr; +}; + +export const notLogged = () => { + const gr: TGuardResult = { ok: true }; + const us = deriveLoginState(); + if (us.isLogged) { + gr.ok = false; + gr.redirect = RouteAlias.home; + } + return gr; +}; + +export const notActivated = () => { + const gr = logged(); + const us = deriveLoginState(); + if (us.isActivated) { + gr.ok = false; + gr.redirect = RouteAlias.home; + } + return gr; +}; + +export const activated = () => { + const gr = logged(); + const us = deriveLoginState(); + if (us.isNotActivated) { + gr.ok = false; + gr.redirect = RouteAlias.activation; + } + return gr; +}; + +export const forbidden = () => { + const gr = logged(); + const us = deriveLoginState(); + if (gr.ok && !us.isForbidden) { + gr.ok = false; + gr.redirect = RouteAlias.home; + } + return gr; +}; + +export const notForbidden = () => { + const gr: TGuardResult = { ok: true }; + const us = deriveLoginState(); + if (us.isForbidden) { + gr.ok = false; + gr.redirect = RouteAlias.suspended; + } + return gr; +}; + +export const admin = () => { + const gr = logged(); + const us = deriveLoginState(); + if (gr.ok && !us.isAdmin) { + gr.ok = false; + gr.redirect = RouteAlias.home; + } + return gr; +}; + +/** + * try user was logged and all state ok + * @param canNavigate // if true, will navigate to login page if not logged + */ +export const tryNormalLogged = (canNavigate: boolean = false) => { + const us = deriveLoginState(); + + if (us.isNormal) { + return true; + } + // must assert logged state first and return + if (!us.isLogged) { + if (canNavigate) { + floppyNavigation.navigateToLogin(); + } + return false; + } + if (us.isNotActivated) { + floppyNavigation.navigate(RouteAlias.activation, () => { + window.location.href = RouteAlias.activation; + }); + } else if (us.isForbidden) { + floppyNavigation.navigate(RouteAlias.suspended, () => { + window.location.replace(RouteAlias.suspended); + }); + } + + return false; +}; + +export const tryLoggedAndActicevated = () => { + const gr: TGuardResult = { ok: true }; + const us = deriveLoginState(); + if (!us.isLogged || !us.isActivated) { + gr.ok = false; + } + return gr; +}; + +export const initAppSettingsStore = async () => { + const appSettings = await getAppSettings(); + if (appSettings) { + siteInfoStore.getState().update(appSettings.general); + interfaceStore.getState().update(appSettings.interface); + } +}; + +export const shouldInitAppFetchData = () => { + const { pathname } = window.location; + if (pathname === '/install') { + return false; + } + + return true; +}; + +export const setupApp = async () => { + /** + * WARN: + * 1. must pre init logged user info for router guard + * 2. must pre init app settings for app render + */ + // TODO: optimize `initAppSettingsStore` by server render + + if (shouldInitAppFetchData()) { + await Promise.allSettled([pullLoggedUser(), initAppSettingsStore()]); + setupAppLanguage(); + setupAppTimeZone(); + } +}; diff --git a/ui/src/utils/index.ts b/ui/src/utils/index.ts index 20cde293..de9e8fef 100644 --- a/ui/src/utils/index.ts +++ b/ui/src/utils/index.ts @@ -1,114 +1,7 @@ -import { LOGIN_NEED_BACK } from '@answer/common/constants'; +export { default as request } from './request'; +export { default as Storage } from './storage'; +export { floppyNavigation } from './floppyNavigation'; -import Storage from './storage'; - -function getQueryString(name: string): string { - const reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`); - const r = window.location.search.substr(1).match(reg); - if (r != null) return unescape(r[2]); - return ''; -} - -function thousandthDivision(num) { - const reg = /\d{1,3}(?=(\d{3})+$)/g; - return `${num}`.replace(reg, '$&,'); -} - -function formatCount($num: number): string { - let res = String($num); - if (!Number.isFinite($num)) { - res = '0'; - } else if ($num < 10000) { - res = thousandthDivision($num); - } else if ($num < 1000000) { - res = `${Math.round($num / 100) / 10}k`; - } else if ($num >= 1000000) { - res = `${Math.round($num / 100000) / 10}m`; - } - return res; -} - -function isLogin(needToLogin?: boolean): boolean { - const user = Storage.get('userInfo'); - const path = window.location.pathname; - - // User deleted or suspended - if (user.username && user.status === 'forbidden') { - if (path !== '/users/account-suspended') { - window.location.pathname = '/users/account-suspended'; - } - return false; - } - - // login and active - if (user.username && user.mail_status === 1) { - if (LOGIN_NEED_BACK.includes(path)) { - window.location.replace('/'); - } - return true; - } - - // un login or inactivated - if ((!user.username || user.mail_status === 2) && needToLogin) { - Storage.set('ANSWER_PATH', path); - window.location.href = '/users/login'; - } - - return false; -} - -function scrollTop(element) { - if (!element) { - return; - } - const offset = 120; - const bodyRect = document.body.getBoundingClientRect().top; - const elementRect = element.getBoundingClientRect().top; - const elementPosition = elementRect - bodyRect; - const offsetPosition = elementPosition - offset; - - window.scrollTo({ - top: offsetPosition, - }); -} - -/** - * Extract user info from markdown - * @param markdown string - * @returns Array<{displayName: string, userName: string}> - */ -function matchedUsers(markdown) { - const globalReg = /\B@([\w|]+)/g; - const reg = /\B@([\w\\_\\.]+)/; - - const users = markdown.match(globalReg); - if (!users) { - return []; - } - return users.map((user) => { - const matched = user.match(reg); - return { - userName: matched[1], - }; - }); -} - -/** - * Identify user infromation from markdown - * @param markdown string - * @returns string - */ -function parseUserInfo(markdown) { - const globalReg = /\B@([\w\\_\\.\\-]+)/g; - return markdown.replace(globalReg, '[@$1](/u/$1)'); -} - -export { - getQueryString, - thousandthDivision, - formatCount, - isLogin, - scrollTop, - matchedUsers, - parseUserInfo, -}; +export * as guard from './guard'; +export * as localize from './localize'; +export * from './common'; diff --git a/ui/src/utils/localize.ts b/ui/src/utils/localize.ts new file mode 100644 index 00000000..5071ee9d --- /dev/null +++ b/ui/src/utils/localize.ts @@ -0,0 +1,100 @@ +import dayjs from 'dayjs'; +import i18next from 'i18next'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; + +import { interfaceStore, loggedUserInfoStore } from '@/stores'; +import { + CURRENT_LANG_STORAGE_KEY, + DEFAULT_LANG, + LANG_RESOURCE_STORAGE_KEY, +} from '@/common/constants'; +import { Storage } from '@/utils'; +import { + getAdminLanguageOptions, + getLanguageConfig, + getLanguageOptions, +} from '@/services'; + +export const loadLanguageOptions = async (forAdmin = false) => { + const languageOptions = forAdmin + ? await getAdminLanguageOptions() + : await getLanguageOptions(); + if (process.env.NODE_ENV === 'development') { + const { default: optConf } = await import('@i18n/i18n.yaml'); + optConf?.language_options.forEach((opt) => { + if (!languageOptions.find((_) => opt.label === _.label)) { + languageOptions.push(opt); + } + }); + } + return languageOptions; +}; + +const addI18nResource = async (langName) => { + const res = { lng: langName, resources: undefined }; + if (process.env.NODE_ENV === 'development') { + try { + const { default: resConf } = await import(`@i18n/${langName}.yaml`); + res.resources = resConf.ui; + } catch (ex) { + console.log('ex: ', ex); + } + } else { + const storageResource = Storage.get(LANG_RESOURCE_STORAGE_KEY); + if (storageResource?.lng === res.lng) { + res.resources = storageResource.resources; + } else { + const langConf = await getLanguageConfig(); + if (langConf) { + res.resources = langConf; + } + } + } + if (res.resources) { + i18next.addResourceBundle( + res.lng, + 'translation', + res.resources, + true, + true, + ); + Storage.set(LANG_RESOURCE_STORAGE_KEY, res); + } +}; + +dayjs.extend(utc); +dayjs.extend(timezone); +const localeDayjs = (langName) => { + langName = langName.replace('_', '-').toLowerCase(); + dayjs.locale(langName); +}; + +export const getCurrentLang = () => { + const loggedUser = loggedUserInfoStore.getState().user; + const adminInterface = interfaceStore.getState().interface; + const fallbackLang = Storage.get(CURRENT_LANG_STORAGE_KEY) || DEFAULT_LANG; + let currentLang = loggedUser.language; + // `default` mean use language value from admin interface + if (/default/i.test(currentLang)) { + currentLang = adminInterface.language; + } + currentLang ||= fallbackLang; + return currentLang; +}; + +export const setupAppLanguage = async () => { + const lang = getCurrentLang(); + if (!i18next.getDataByLanguage(lang)) { + await addI18nResource(lang); + } + localeDayjs(lang); + i18next.changeLanguage(lang); +}; + +export const setupAppTimeZone = () => { + const adminInterface = interfaceStore.getState().interface; + if (adminInterface.time_zone) { + dayjs.tz.setDefault(adminInterface.time_zone); + } +}; diff --git a/ui/src/utils/request.ts b/ui/src/utils/request.ts index d532d707..7859b90d 100644 --- a/ui/src/utils/request.ts +++ b/ui/src/utils/request.ts @@ -1,10 +1,14 @@ import axios, { AxiosResponse } from 'axios'; import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'; -import { Modal } from '@answer/components'; -import { userInfoStore, toastStore } from '@answer/stores'; +import { Modal } from '@/components'; +import { loggedUserInfoStore, toastStore } from '@/stores'; +import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants'; +import { RouteAlias } from '@/router/alias'; +import { getCurrentLang } from '@/utils/localize'; import Storage from './storage'; +import { floppyNavigation } from './floppyNavigation'; const API = { development: '', @@ -25,12 +29,10 @@ class Request { constructor(config: AxiosRequestConfig) { this.instance = axios.create(config); - this.instance.interceptors.request.use( (requestConfig: AxiosRequestConfig) => { - const token = Storage.get('token') || ''; - // default lang en_US - const lang = Storage.get('LANG') || 'en_US'; + const token = Storage.get(LOGGED_TOKEN_STORAGE_KEY) || ''; + const lang = getCurrentLang(); requestConfig.headers = { Authorization: token, 'Accept-Language': lang, @@ -54,23 +56,30 @@ class Request { return data; }, (error) => { - const { status, data, msg } = error.response; - const { data: realData, msg: realMsg = '' } = data; + const { status, data: respData, msg: respMsg } = error.response; + const { data, msg = '' } = respData; if (status === 400) { // show error message - if (realData instanceof Object && realData.err_type) { - if (realData.err_type === 'toast') { + if (data instanceof Object && data.err_type) { + if (data.err_type === 'toast') { // toast error message toastStore.getState().show({ - msg: realMsg, + msg, variant: 'danger', }); } - if (realData.type === 'modal') { + if (data.err_type === 'alert') { + return Promise.reject({ + msg, + ...data, + }); + } + + if (data.err_type === 'modal') { // modal error message Modal.confirm({ - content: realMsg, + content: msg, }); } @@ -78,65 +87,59 @@ class Request { } if ( - realData instanceof Object && - Object.keys(realData).length > 0 && - realData.key + data instanceof Object && + Object.keys(data).length > 0 && + data.key ) { // handle form error - return Promise.reject({ ...realData, isError: true }); + return Promise.reject({ ...data, isError: true }); } - if (!realData || Object.keys(realData).length <= 0) { + if (!data || Object.keys(data).length <= 0) { // default error msg will show modal Modal.confirm({ - content: realMsg, + content: msg, }); return Promise.reject(false); } } - + // 401: Re-login required if (status === 401) { - // clear userinfo; - Storage.remove('token'); - userInfoStore.getState().clear(); - // need login - const { pathname } = window.location; - if (pathname !== '/users/login' && pathname !== '/users/register') { - Storage.set('ANSWER_PATH', window.location.pathname); - } - window.location.href = '/users/login'; - + // clear userinfo + loggedUserInfoStore.getState().clear(); + floppyNavigation.navigateToLogin(); return Promise.reject(false); } - if (status === 403) { // Permission interception - - if (realData?.type === 'inactive') { - // inactivated - window.location.href = '/users/login?status=inactive'; - return Promise.reject(false); - } - - if (realData?.type === 'url_expired') { + if (data?.type === 'url_expired') { // url expired - window.location.href = '/users/account-activation/failed'; + floppyNavigation.navigate(RouteAlias.activationFailed, () => { + window.location.replace(RouteAlias.activationFailed); + }); + return Promise.reject(false); + } + if (data?.type === 'inactive') { + // inactivated + floppyNavigation.navigate(RouteAlias.activation, () => { + window.location.href = RouteAlias.activation; + }); return Promise.reject(false); } - if (realData?.type === 'suspended') { - if (window.location.pathname !== '/users/account-suspended') { - window.location.href = '/users/account-suspended'; - } - + if (data?.type === 'suspended') { + floppyNavigation.navigate(RouteAlias.suspended, () => { + window.location.replace(RouteAlias.suspended); + }); return Promise.reject(false); } } - - toastStore.getState().show({ - msg: `statusCode: ${status}; ${msg || ''}`, - variant: 'danger', - }); + if (respMsg) { + toastStore.getState().show({ + msg: `statusCode: ${status}; ${respMsg || ''}`, + variant: 'danger', + }); + } return Promise.reject(false); }, ); @@ -178,6 +181,4 @@ class Request { } } -// export const Request; - export default new Request(baseConfig); diff --git a/ui/src/utils/storage.ts b/ui/src/utils/storage.ts index bf14d85e..0ab3b115 100644 --- a/ui/src/utils/storage.ts +++ b/ui/src/utils/storage.ts @@ -3,13 +3,12 @@ const Storage = { const value = localStorage.getItem(key); if (value) { try { - const v = JSON.parse(value); - return v; + return JSON.parse(value); } catch { return value; } } - return false; + return undefined; }, set: (key: string, value: any): void => { if (typeof value === 'string') { diff --git a/ui/tsconfig.json b/ui/tsconfig.json index c3804747..afe1b5b9 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -21,19 +21,8 @@ "baseUrl": "./", "paths": { "@/*": ["src/*"], - "@answer/pages/*": ["src/pages/*"], - "@answer/components": ["src/components/index.ts"], - "@answer/components/*": ["src/components/*"], - "@answer/stores": ["src/stores"], - "@answer/stores/*": ["src/stores/*"], - "@answer/api": ["src/services/api.ts"], - "@answer/services/*": ["src/services/*"], - "@answer/hooks": ["src/hooks"], - "@answer/common": ["src/common"], - "@answer/common/*": ["src/common/*"], - "@answer/utils": ["src/utils"], - "@answer/utils/*": ["src/utils/*"] + "@i18n/*": ["../i18n/*"] } }, - "include": ["src", "node_modules/@testing-library/jest-dom"] + "include": ["src", "node_modules/@testing-library/jest-dom" ] }
{t('name')}{t('reputation')}{t('name')}{t('reputation')} {t('email')} + {t('created_at')} + {curFilter === 'deleted' ? t('delete_at') : t('suspend_at')} {t('status')}{t('action')}{t('status')}{t('action')}
{user.status !== 'deleted' && (