Merge branch 'release/0.3.0' into 'main'

Release/0.3.0

See merge request opensource/answer!227
This commit is contained in:
linkinstar 2022-11-11 06:24:10 +00:00
commit 2b1ad94791
228 changed files with 7352 additions and 3689 deletions

2
.gitignore vendored
View File

@ -9,7 +9,7 @@
/.fleet /.fleet
/.vscode/*.log /.vscode/*.log
/cmd/answer/*.sh /cmd/answer/*.sh
/cmd/answer/upfiles/* /cmd/answer/uploads/*
/cmd/logs /cmd/logs
/configs/config-dev.yaml /configs/config-dev.yaml
/go.work* /go.work*

View File

@ -29,7 +29,7 @@ RUN apk --no-cache add build-base git \
&& make clean build \ && make clean build \
&& cp answer /usr/bin/answer && 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 && mkdir -p /data/i18n && cp -r i18n/*.yaml /data/i18n
# stage3 copy the binary and resource files into fresh container # stage3 copy the binary and resource files into fresh container

View File

@ -91,7 +91,7 @@ swaggerui:
service_config: service_config:
secret_key: "answer" #encryption key secret_key: "answer" #encryption key
web_host: "http://127.0.0.1" #Page access using domain name address 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 ## 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 . docker build -t answer:v1.0.0 .
``` ```
## common problem ## 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.

View File

@ -1,80 +1,54 @@
# Answer 安装指引 # Answer 安装指引
安装 Answer 之前,您需要先安装基本环境。 ## 使用 docker 安装
- 数据库 ### 步骤 1: 使用 docker 命令启动项目
- [MySQL](http://dev.mysql.com):版本 >= 5.7
然后,您可以通过以下几种方式来安装 Answer
- 采用 Docker 部署
- 二进制安装
- 源码安装
## 使用 Docker-compose 安装 Answer
```bash ```bash
$ mkdir answer && cd answer docker run -d -p 9080:80 -v answer-data:/data --name answer answerdev/answer:latest
$ wget https://raw.githubusercontent.com/answerdev/answer/main/docker-compose.yaml ```
$ docker-compose up ### 步骤 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 ### 步骤 3安装完成后访问项目路径开始使用
可以从 Docker Hub 或者 GitHub Container registry 下载最新的 tags 镜像 [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)
请下载您当下系统所需要的对应版本
``` ### 步骤 2: 使用命令行安装
# 将镜像从 docker hub 拉到本地 > 以下命令中 -C 指定的是 answer 所需的数据目录,您可以根据实际需要进行修改
$ 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 参数说明
```bash
./answer init -C ./answer-data/
``` ```
server: server:
http: http:

View File

@ -4,14 +4,14 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/answerdev/answer/internal/base/conf"
"github.com/answerdev/answer/internal/cli" "github.com/answerdev/answer/internal/cli"
"github.com/answerdev/answer/internal/install"
"github.com/answerdev/answer/internal/migrations" "github.com/answerdev/answer/internal/migrations"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ( var (
// configFilePath is the config file path
configFilePath string
// dataDirPath save all answer application data in this directory. like config file, upload file... // dataDirPath save all answer application data in this directory. like config file, upload file...
dataDirPath string dataDirPath string
// dumpDataPath dump data path // dumpDataPath dump data path
@ -21,9 +21,7 @@ var (
func init() { func init() {
rootCmd.Version = fmt.Sprintf("%s\nrevision: %s\nbuild time: %s", Version, Revision, Time) 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(&dataDirPath, "data-path", "C", "/data/", "data path, eg: -C ./data/")
rootCmd.PersistentFlags().StringVarP(&configFilePath, "config", "c", "", "config path, eg: -c config.yaml")
dumpCmd.Flags().StringVarP(&dumpDataPath, "path", "p", "./", "dump data path, eg: -p ./dump/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", Short: "Run the application",
Long: `Run the application`, Long: `Run the application`,
Run: func(_ *cobra.Command, _ []string) { Run: func(_ *cobra.Command, _ []string) {
cli.FormatAllPath(dataDirPath)
fmt.Println("config file path: ", cli.GetConfigFilePath())
fmt.Println("Answer is string..........................")
runApp() runApp()
}, },
} }
@ -59,18 +60,27 @@ To run answer, use:
Short: "init answer application", Short: "init answer application",
Long: `init answer application`, Long: `init answer application`,
Run: func(_ *cobra.Command, _ []string) { Run: func(_ *cobra.Command, _ []string) {
// check config file and database. if config file exists and database is already created, init done
cli.InstallAllInitialEnvironment(dataDirPath) cli.InstallAllInitialEnvironment(dataDirPath)
c, err := readConfig()
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 { if err != nil {
fmt.Println("read config failed: ", err.Error()) fmt.Println("read config failed: ", err.Error())
return return
} }
fmt.Println("read config successfully")
if err := migrations.InitDB(c.Data.Database); err != nil { fmt.Println("config file read successfully, try to connect database...")
fmt.Println("init database error: ", err.Error()) if cli.CheckDBTableExist(c.Data.Database) {
fmt.Println("connect to database successfully and table already exists, do nothing.")
return 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", Short: "upgrade Answer version",
Long: `upgrade Answer version`, Long: `upgrade Answer version`,
Run: func(_ *cobra.Command, _ []string) { Run: func(_ *cobra.Command, _ []string) {
c, err := readConfig() cli.FormatAllPath(dataDirPath)
c, err := conf.ReadConfig(cli.GetConfigFilePath())
if err != nil { if err != nil {
fmt.Println("read config failed: ", err.Error()) fmt.Println("read config failed: ", err.Error())
return return
@ -100,7 +111,8 @@ To run answer, use:
Long: `back up data`, Long: `back up data`,
Run: func(_ *cobra.Command, _ []string) { Run: func(_ *cobra.Command, _ []string) {
fmt.Println("Answer is backing up data") fmt.Println("Answer is backing up data")
c, err := readConfig() cli.FormatAllPath(dataDirPath)
c, err := conf.ReadConfig(cli.GetConfigFilePath())
if err != nil { if err != nil {
fmt.Println("read config failed: ", err.Error()) fmt.Println("read config failed: ", err.Error())
return return
@ -120,8 +132,9 @@ To run answer, use:
Short: "checking the required environment", Short: "checking the required environment",
Long: `Check if the current environment meets the startup requirements`, Long: `Check if the current environment meets the startup requirements`,
Run: func(_ *cobra.Command, _ []string) { Run: func(_ *cobra.Command, _ []string) {
cli.FormatAllPath(dataDirPath)
fmt.Println("Start checking the required environment...") fmt.Println("Start checking the required environment...")
if cli.CheckConfigFile(configFilePath) { if cli.CheckConfigFile(cli.GetConfigFilePath()) {
fmt.Println("config file exists [✔]") fmt.Println("config file exists [✔]")
} else { } else {
fmt.Println("config file not exists [x]") fmt.Println("config file not exists [x]")
@ -133,13 +146,13 @@ To run answer, use:
fmt.Println("upload directory not exists [x]") fmt.Println("upload directory not exists [x]")
} }
c, err := readConfig() c, err := conf.ReadConfig(cli.GetConfigFilePath())
if err != nil { if err != nil {
fmt.Println("read config failed: ", err.Error()) fmt.Println("read config failed: ", err.Error())
return return
} }
if cli.CheckDB(c.Data.Database) { if cli.CheckDBConnection(c.Data.Database) {
fmt.Println("db connection successfully [✔]") fmt.Println("db connection successfully [✔]")
} else { } else {
fmt.Println("db connection failed [x]") fmt.Println("db connection failed [x]")

View File

@ -2,13 +2,14 @@ package main
import ( import (
"os" "os"
"path/filepath" "time"
"github.com/answerdev/answer/internal/base/conf" "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/cli"
"github.com/answerdev/answer/internal/schema"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/segmentfault/pacman" "github.com/segmentfault/pacman"
"github.com/segmentfault/pacman/contrib/conf/viper"
"github.com/segmentfault/pacman/contrib/log/zap" "github.com/segmentfault/pacman/contrib/log/zap"
"github.com/segmentfault/pacman/contrib/server/http" "github.com/segmentfault/pacman/contrib/server/http"
"github.com/segmentfault/pacman/log" "github.com/segmentfault/pacman/log"
@ -40,8 +41,7 @@ func main() {
func runApp() { func runApp() {
log.SetLogger(zap.NewLogger( log.SetLogger(zap.NewLogger(
log.ParseLevel(logLevel), zap.WithName("answer"), zap.WithPath(logPath), zap.WithCallerFullPath())) log.ParseLevel(logLevel), zap.WithName("answer"), zap.WithPath(logPath), zap.WithCallerFullPath()))
c, err := conf.ReadConfig(cli.GetConfigFilePath())
c, err := readConfig()
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -50,27 +50,15 @@ func runApp() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
constant.Version = Version
schema.AppStartTime = time.Now()
defer cleanup() defer cleanup()
if err := app.Run(); err != nil { if err := app.Run(); err != nil {
panic(err) 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 { func newApplication(serverConf *conf.Server, server *gin.Engine) *pacman.Application {
return pacman.NewApp( return pacman.NewApp(
pacman.WithName(Name), pacman.WithName(Name),

View File

@ -44,6 +44,7 @@ import (
auth2 "github.com/answerdev/answer/internal/service/auth" auth2 "github.com/answerdev/answer/internal/service/auth"
"github.com/answerdev/answer/internal/service/collection_common" "github.com/answerdev/answer/internal/service/collection_common"
comment2 "github.com/answerdev/answer/internal/service/comment" comment2 "github.com/answerdev/answer/internal/service/comment"
"github.com/answerdev/answer/internal/service/dashboard"
export2 "github.com/answerdev/answer/internal/service/export" export2 "github.com/answerdev/answer/internal/service/export"
"github.com/answerdev/answer/internal/service/follow" "github.com/answerdev/answer/internal/service/follow"
meta2 "github.com/answerdev/answer/internal/service/meta" 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/report_handle_backyard"
"github.com/answerdev/answer/internal/service/revision_common" "github.com/answerdev/answer/internal/service/revision_common"
"github.com/answerdev/answer/internal/service/service_config" "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" tag2 "github.com/answerdev/answer/internal/service/tag"
"github.com/answerdev/answer/internal/service/tag_common" "github.com/answerdev/answer/internal/service/tag_common"
"github.com/answerdev/answer/internal/service/uploader" "github.com/answerdev/answer/internal/service/uploader"
@ -76,7 +79,6 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
langController := controller.NewLangController(i18nTranslator)
engine, err := data.NewDB(debug, dbConf) engine, err := data.NewDB(debug, dbConf)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@ -90,6 +92,9 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
cleanup() cleanup()
return nil, nil, err return nil, nil, err
} }
siteInfoRepo := site_info.NewSiteInfo(dataData)
siteInfoCommonService := siteinfo_common.NewSiteInfoCommonService(siteInfoRepo)
langController := controller.NewLangController(i18nTranslator, siteInfoCommonService)
authRepo := auth.NewAuthRepo(dataData) authRepo := auth.NewAuthRepo(dataData)
authService := auth2.NewAuthService(authRepo) authService := auth2.NewAuthService(authRepo)
configRepo := config.NewConfigRepo(dataData) configRepo := config.NewConfigRepo(dataData)
@ -99,12 +104,11 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
userRankRepo := rank.NewUserRankRepo(dataData, configRepo) userRankRepo := rank.NewUserRankRepo(dataData, configRepo)
userActiveActivityRepo := activity.NewUserActiveActivityRepo(dataData, activityRepo, userRankRepo, configRepo) userActiveActivityRepo := activity.NewUserActiveActivityRepo(dataData, activityRepo, userRankRepo, configRepo)
emailRepo := export.NewEmailRepo(dataData) emailRepo := export.NewEmailRepo(dataData)
siteInfoRepo := site_info.NewSiteInfo(dataData)
emailService := export2.NewEmailService(configRepo, emailRepo, siteInfoRepo) 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) captchaRepo := captcha.NewCaptchaRepo(dataData)
captchaService := action.NewCaptchaService(captchaRepo) captchaService := action.NewCaptchaService(captchaRepo)
uploaderService := uploader.NewUploaderService(serviceConf) uploaderService := uploader.NewUploaderService(serviceConf, siteInfoCommonService)
userController := controller.NewUserController(authService, userService, captchaService, emailService, uploaderService) userController := controller.NewUserController(authService, userService, captchaService, emailService, uploaderService)
commentRepo := comment.NewCommentRepo(dataData, uniqueIDRepo) commentRepo := comment.NewCommentRepo(dataData, uniqueIDRepo)
commentCommonRepo := comment.NewCommentCommonRepo(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) questionService := service.NewQuestionService(questionRepo, tagCommonService, questionCommon, userCommon, revisionService, metaService, collectionCommon, answerActivityService)
questionController := controller.NewQuestionController(questionService, rankService) questionController := controller.NewQuestionController(questionService, rankService)
answerService := service.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo) 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) searchRepo := search_common.NewSearchRepo(dataData, uniqueIDRepo, userCommon)
searchService := service.NewSearchService(searchRepo, tagRepo, userCommon, followRepo) searchService := service.NewSearchService(searchRepo, tagRepo, userCommon, followRepo)
searchController := controller.NewSearchController(searchService) searchController := controller.NewSearchController(searchService)
@ -166,14 +171,15 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
reasonService := reason2.NewReasonService(reasonRepo) reasonService := reason2.NewReasonService(reasonRepo)
reasonController := controller.NewReasonController(reasonService) reasonController := controller.NewReasonController(reasonService)
themeController := controller_backyard.NewThemeController() themeController := controller_backyard.NewThemeController()
siteInfoService := service.NewSiteInfoService(siteInfoRepo, emailService) siteInfoService := siteinfo.NewSiteInfoService(siteInfoRepo, emailService)
siteInfoController := controller_backyard.NewSiteInfoController(siteInfoService) siteInfoController := controller_backyard.NewSiteInfoController(siteInfoService)
siteinfoController := controller.NewSiteinfoController(siteInfoService) siteinfoController := controller.NewSiteinfoController(siteInfoCommonService)
notificationRepo := notification.NewNotificationRepo(dataData) notificationRepo := notification.NewNotificationRepo(dataData)
notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService) notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService)
notificationService := notification2.NewNotificationService(dataData, notificationRepo, notificationCommon) notificationService := notification2.NewNotificationService(dataData, notificationRepo, notificationCommon)
notificationController := controller.NewNotificationController(notificationService) 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) swaggerRouter := router.NewSwaggerRouter(swaggerConf)
uiRouter := router.NewUIRouter() uiRouter := router.NewUIRouter()
authUserMiddleware := middleware.NewAuthUserMiddleware(authService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService)

View File

@ -17,4 +17,4 @@ swaggerui:
service_config: service_config:
secret_key: "answer" secret_key: "answer"
web_host: "http://127.0.0.1:9080" web_host: "http://127.0.0.1:9080"
upload_path: "/data/upfiles" upload_path: "/data/uploads"

View File

@ -62,12 +62,6 @@ const docTemplate = `{
"description": "answer id or question title", "description": "answer id or question title",
"name": "query", "name": "query",
"in": "query" "in": "query"
},
{
"type": "string",
"description": "question id",
"name": "question_id",
"in": "query"
} }
], ],
"responses": { "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": { "/answer/admin/api/language/options": {
"get": { "get": {
"security": [ "security": [
@ -487,14 +509,14 @@ const docTemplate = `{
"ApiKeyAuth": [] "ApiKeyAuth": []
} }
], ],
"description": "Get siteinfo general", "description": "get site general information",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"admin" "admin"
], ],
"summary": "Get siteinfo general", "summary": "get site general information",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@ -522,14 +544,14 @@ const docTemplate = `{
"ApiKeyAuth": [] "ApiKeyAuth": []
} }
], ],
"description": "Get siteinfo interface", "description": "update site general information",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"admin" "admin"
], ],
"summary": "Get siteinfo interface", "summary": "update site general information",
"parameters": [ "parameters": [
{ {
"description": "general", "description": "general",
@ -558,25 +580,14 @@ const docTemplate = `{
"ApiKeyAuth": [] "ApiKeyAuth": []
} }
], ],
"description": "Get siteinfo interface", "description": "get site interface",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"admin" "admin"
], ],
"summary": "Get siteinfo interface", "summary": "get site interface",
"parameters": [
{
"description": "general",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.AddCommentReq"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@ -604,14 +615,14 @@ const docTemplate = `{
"ApiKeyAuth": [] "ApiKeyAuth": []
} }
], ],
"description": "Get siteinfo interface", "description": "update site info interface",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"admin" "admin"
], ],
"summary": "Get siteinfo interface", "summary": "update site info interface",
"parameters": [ "parameters": [
{ {
"description": "general", "description": "general",
@ -2710,14 +2721,14 @@ const docTemplate = `{
}, },
"/answer/api/v1/siteinfo": { "/answer/api/v1/siteinfo": {
"get": { "get": {
"description": "Get siteinfo", "description": "get site info",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"site" "site"
], ],
"summary": "Get siteinfo", "summary": "get site info",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@ -5281,11 +5292,17 @@ const docTemplate = `{
"schema.SiteGeneralReq": { "schema.SiteGeneralReq": {
"type": "object", "type": "object",
"required": [ "required": [
"contact_email",
"description", "description",
"name", "name",
"short_description" "short_description",
"site_url"
], ],
"properties": { "properties": {
"contact_email": {
"type": "string",
"maxLength": 512
},
"description": { "description": {
"type": "string", "type": "string",
"maxLength": 2000 "maxLength": 2000
@ -5297,17 +5314,27 @@ const docTemplate = `{
"short_description": { "short_description": {
"type": "string", "type": "string",
"maxLength": 255 "maxLength": 255
},
"site_url": {
"type": "string",
"maxLength": 512
} }
} }
}, },
"schema.SiteGeneralResp": { "schema.SiteGeneralResp": {
"type": "object", "type": "object",
"required": [ "required": [
"contact_email",
"description", "description",
"name", "name",
"short_description" "short_description",
"site_url"
], ],
"properties": { "properties": {
"contact_email": {
"type": "string",
"maxLength": 512
},
"description": { "description": {
"type": "string", "type": "string",
"maxLength": 2000 "maxLength": 2000
@ -5319,6 +5346,10 @@ const docTemplate = `{
"short_description": { "short_description": {
"type": "string", "type": "string",
"maxLength": 255 "maxLength": 255
},
"site_url": {
"type": "string",
"maxLength": 512
} }
} }
}, },
@ -5326,7 +5357,8 @@ const docTemplate = `{
"type": "object", "type": "object",
"required": [ "required": [
"language", "language",
"theme" "theme",
"time_zone"
], ],
"properties": { "properties": {
"language": { "language": {
@ -5340,6 +5372,10 @@ const docTemplate = `{
"theme": { "theme": {
"type": "string", "type": "string",
"maxLength": 128 "maxLength": 128
},
"time_zone": {
"type": "string",
"maxLength": 128
} }
} }
}, },
@ -5347,7 +5383,8 @@ const docTemplate = `{
"type": "object", "type": "object",
"required": [ "required": [
"language", "language",
"theme" "theme",
"time_zone"
], ],
"properties": { "properties": {
"language": { "language": {
@ -5361,6 +5398,10 @@ const docTemplate = `{
"theme": { "theme": {
"type": "string", "type": "string",
"maxLength": 128 "maxLength": 128
},
"time_zone": {
"type": "string",
"maxLength": 128
} }
} }
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -50,12 +50,6 @@
"description": "answer id or question title", "description": "answer id or question title",
"name": "query", "name": "query",
"in": "query" "in": "query"
},
{
"type": "string",
"description": "question id",
"name": "question_id",
"in": "query"
} }
], ],
"responses": { "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": { "/answer/admin/api/language/options": {
"get": { "get": {
"security": [ "security": [
@ -475,14 +497,14 @@
"ApiKeyAuth": [] "ApiKeyAuth": []
} }
], ],
"description": "Get siteinfo general", "description": "get site general information",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"admin" "admin"
], ],
"summary": "Get siteinfo general", "summary": "get site general information",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@ -510,14 +532,14 @@
"ApiKeyAuth": [] "ApiKeyAuth": []
} }
], ],
"description": "Get siteinfo interface", "description": "update site general information",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"admin" "admin"
], ],
"summary": "Get siteinfo interface", "summary": "update site general information",
"parameters": [ "parameters": [
{ {
"description": "general", "description": "general",
@ -546,25 +568,14 @@
"ApiKeyAuth": [] "ApiKeyAuth": []
} }
], ],
"description": "Get siteinfo interface", "description": "get site interface",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"admin" "admin"
], ],
"summary": "Get siteinfo interface", "summary": "get site interface",
"parameters": [
{
"description": "general",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.AddCommentReq"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@ -592,14 +603,14 @@
"ApiKeyAuth": [] "ApiKeyAuth": []
} }
], ],
"description": "Get siteinfo interface", "description": "update site info interface",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"admin" "admin"
], ],
"summary": "Get siteinfo interface", "summary": "update site info interface",
"parameters": [ "parameters": [
{ {
"description": "general", "description": "general",
@ -2698,14 +2709,14 @@
}, },
"/answer/api/v1/siteinfo": { "/answer/api/v1/siteinfo": {
"get": { "get": {
"description": "Get siteinfo", "description": "get site info",
"produces": [ "produces": [
"application/json" "application/json"
], ],
"tags": [ "tags": [
"site" "site"
], ],
"summary": "Get siteinfo", "summary": "get site info",
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@ -5269,11 +5280,17 @@
"schema.SiteGeneralReq": { "schema.SiteGeneralReq": {
"type": "object", "type": "object",
"required": [ "required": [
"contact_email",
"description", "description",
"name", "name",
"short_description" "short_description",
"site_url"
], ],
"properties": { "properties": {
"contact_email": {
"type": "string",
"maxLength": 512
},
"description": { "description": {
"type": "string", "type": "string",
"maxLength": 2000 "maxLength": 2000
@ -5285,17 +5302,27 @@
"short_description": { "short_description": {
"type": "string", "type": "string",
"maxLength": 255 "maxLength": 255
},
"site_url": {
"type": "string",
"maxLength": 512
} }
} }
}, },
"schema.SiteGeneralResp": { "schema.SiteGeneralResp": {
"type": "object", "type": "object",
"required": [ "required": [
"contact_email",
"description", "description",
"name", "name",
"short_description" "short_description",
"site_url"
], ],
"properties": { "properties": {
"contact_email": {
"type": "string",
"maxLength": 512
},
"description": { "description": {
"type": "string", "type": "string",
"maxLength": 2000 "maxLength": 2000
@ -5307,6 +5334,10 @@
"short_description": { "short_description": {
"type": "string", "type": "string",
"maxLength": 255 "maxLength": 255
},
"site_url": {
"type": "string",
"maxLength": 512
} }
} }
}, },
@ -5314,7 +5345,8 @@
"type": "object", "type": "object",
"required": [ "required": [
"language", "language",
"theme" "theme",
"time_zone"
], ],
"properties": { "properties": {
"language": { "language": {
@ -5328,6 +5360,10 @@
"theme": { "theme": {
"type": "string", "type": "string",
"maxLength": 128 "maxLength": 128
},
"time_zone": {
"type": "string",
"maxLength": 128
} }
} }
}, },
@ -5335,7 +5371,8 @@
"type": "object", "type": "object",
"required": [ "required": [
"language", "language",
"theme" "theme",
"time_zone"
], ],
"properties": { "properties": {
"language": { "language": {
@ -5349,6 +5386,10 @@
"theme": { "theme": {
"type": "string", "type": "string",
"maxLength": 128 "maxLength": 128
},
"time_zone": {
"type": "string",
"maxLength": 128
} }
} }
}, },

View File

@ -983,6 +983,9 @@ definitions:
type: object type: object
schema.SiteGeneralReq: schema.SiteGeneralReq:
properties: properties:
contact_email:
maxLength: 512
type: string
description: description:
maxLength: 2000 maxLength: 2000
type: string type: string
@ -992,13 +995,21 @@ definitions:
short_description: short_description:
maxLength: 255 maxLength: 255
type: string type: string
site_url:
maxLength: 512
type: string
required: required:
- contact_email
- description - description
- name - name
- short_description - short_description
- site_url
type: object type: object
schema.SiteGeneralResp: schema.SiteGeneralResp:
properties: properties:
contact_email:
maxLength: 512
type: string
description: description:
maxLength: 2000 maxLength: 2000
type: string type: string
@ -1008,10 +1019,15 @@ definitions:
short_description: short_description:
maxLength: 255 maxLength: 255
type: string type: string
site_url:
maxLength: 512
type: string
required: required:
- contact_email
- description - description
- name - name
- short_description - short_description
- site_url
type: object type: object
schema.SiteInterfaceReq: schema.SiteInterfaceReq:
properties: properties:
@ -1024,9 +1040,13 @@ definitions:
theme: theme:
maxLength: 128 maxLength: 128
type: string type: string
time_zone:
maxLength: 128
type: string
required: required:
- language - language
- theme - theme
- time_zone
type: object type: object
schema.SiteInterfaceResp: schema.SiteInterfaceResp:
properties: properties:
@ -1039,9 +1059,13 @@ definitions:
theme: theme:
maxLength: 128 maxLength: 128
type: string type: string
time_zone:
maxLength: 128
type: string
required: required:
- language - language
- theme - theme
- time_zone
type: object type: object
schema.TagItem: schema.TagItem:
properties: properties:
@ -1394,10 +1418,6 @@ paths:
in: query in: query
name: query name: query
type: string type: string
- description: question id
in: query
name: question_id
type: string
produces: produces:
- application/json - application/json
responses: responses:
@ -1434,6 +1454,23 @@ paths:
summary: AdminSetAnswerStatus summary: AdminSetAnswerStatus
tags: tags:
- admin - 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: /answer/admin/api/language/options:
get: get:
description: Get language options description: Get language options
@ -1662,7 +1699,7 @@ paths:
- admin - admin
/answer/admin/api/siteinfo/general: /answer/admin/api/siteinfo/general:
get: get:
description: Get siteinfo general description: get site general information
produces: produces:
- application/json - application/json
responses: responses:
@ -1677,11 +1714,11 @@ paths:
type: object type: object
security: security:
- ApiKeyAuth: [] - ApiKeyAuth: []
summary: Get siteinfo general summary: get site general information
tags: tags:
- admin - admin
put: put:
description: Get siteinfo interface description: update site general information
parameters: parameters:
- description: general - description: general
in: body in: body
@ -1698,19 +1735,12 @@ paths:
$ref: '#/definitions/handler.RespBody' $ref: '#/definitions/handler.RespBody'
security: security:
- ApiKeyAuth: [] - ApiKeyAuth: []
summary: Get siteinfo interface summary: update site general information
tags: tags:
- admin - admin
/answer/admin/api/siteinfo/interface: /answer/admin/api/siteinfo/interface:
get: get:
description: Get siteinfo interface description: get site interface
parameters:
- description: general
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.AddCommentReq'
produces: produces:
- application/json - application/json
responses: responses:
@ -1725,11 +1755,11 @@ paths:
type: object type: object
security: security:
- ApiKeyAuth: [] - ApiKeyAuth: []
summary: Get siteinfo interface summary: get site interface
tags: tags:
- admin - admin
put: put:
description: Get siteinfo interface description: update site info interface
parameters: parameters:
- description: general - description: general
in: body in: body
@ -1746,7 +1776,7 @@ paths:
$ref: '#/definitions/handler.RespBody' $ref: '#/definitions/handler.RespBody'
security: security:
- ApiKeyAuth: [] - ApiKeyAuth: []
summary: Get siteinfo interface summary: update site info interface
tags: tags:
- admin - admin
/answer/admin/api/theme/options: /answer/admin/api/theme/options:
@ -3014,7 +3044,7 @@ paths:
- Search - Search
/answer/api/v1/siteinfo: /answer/api/v1/siteinfo:
get: get:
description: Get siteinfo description: get site info
produces: produces:
- application/json - application/json
responses: responses:
@ -3027,7 +3057,7 @@ paths:
data: data:
$ref: '#/definitions/schema.SiteGeneralResp' $ref: '#/definitions/schema.SiteGeneralResp'
type: object type: object
summary: Get siteinfo summary: get site info
tags: tags:
- site - site
/answer/api/v1/tag: /answer/api/v1/tag:

5
go.mod
View File

@ -15,6 +15,7 @@ require (
github.com/goccy/go-json v0.9.11 github.com/goccy/go-json v0.9.11
github.com/google/uuid v1.3.0 github.com/google/uuid v1.3.0
github.com/google/wire v0.5.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/copier v0.3.5
github.com/jinzhu/now v1.1.5 github.com/jinzhu/now v1.1.5
github.com/lib/pq v1.10.7 github.com/lib/pq v1.10.7
@ -24,7 +25,7 @@ require (
github.com/segmentfault/pacman v1.0.1 github.com/segmentfault/pacman v1.0.1
github.com/segmentfault/pacman/contrib/cache/memory v0.0.0-20221018072427-a15dd1434e05 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/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/log/zap v0.0.0-20221018072427-a15dd1434e05
github.com/segmentfault/pacman/contrib/server/http v0.0.0-20221018072427-a15dd1434e05 github.com/segmentfault/pacman/contrib/server/http v0.0.0-20221018072427-a15dd1434e05
github.com/spf13/cobra v1.6.1 github.com/spf13/cobra v1.6.1
@ -35,6 +36,7 @@ require (
golang.org/x/crypto v0.1.0 golang.org/x/crypto v0.1.0
golang.org/x/net v0.1.0 golang.org/x/net v0.1.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
xorm.io/builder v0.3.12 xorm.io/builder v0.3.12
xorm.io/core v0.7.3 xorm.io/core v0.7.3
xorm.io/xorm v1.3.2 xorm.io/xorm v1.3.2
@ -110,6 +112,5 @@ require (
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.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 sigs.k8s.io/yaml v1.3.0 // indirect
) )

6
go.sum
View File

@ -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.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/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/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-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/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= 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/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 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/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-20221109042453-26158da67632 h1:so07u8RWXZQ0gz30KXJ9MKtQ5zjgcDlQ/UwFZrwm5b0=
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/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 h1:jcGZU2juv0L3eFEkuZYV14ESLUlWfGMWnP0mjOfrSZc=
github.com/segmentfault/pacman/contrib/log/zap v0.0.0-20221018072427-a15dd1434e05/go.mod h1:L4GqtXLoR73obTYqUQIzfkm8NG8pvZafxFb6KZFSSHk= 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= github.com/segmentfault/pacman/contrib/server/http v0.0.0-20221018072427-a15dd1434e05 h1:91is1nKNbfTOl8CvMYiFgg4c5Vmol+5mVmMV/jDXD+A=

View File

@ -1,3 +1,5 @@
# The following fields are used for back-end
backend:
base: base:
success: success:
other: "Success." other: "Success."
@ -89,6 +91,17 @@ error:
set_avatar: set_avatar:
other: "Avatar set failed." 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: "Cant create the config.yaml file."
report: report:
spam: spam:
name: name:
@ -170,3 +183,946 @@ notification:
other: "Your answer has been deleted" other: "Your answer has been deleted"
your_comment_was_deleted: your_comment_was_deleted:
other: "Your comment has been 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: >-
<ul class="mb-0"><li><p class="mb-2">to make links</p><pre
class="mb-2"><code>&lt;https://url.com&gt;<br/><br/>[Title](https://url.com)</code></pre></li><li><p
class="mb-2">put returns between paragraphs</p></li><li><p
class="mb-2"><em>_italic_</em> or **<strong>bold</strong>**</p></li><li><p
class="mb-2">indent code by 4 spaces</p></li><li><p class="mb-2">quote by
placing <code>&gt;</code> at start of line</p></li><li><p
class="mb-2">backtick escapes <code>`like _this_`</code></p></li><li><p
class="mb-2">create code fences with backticks <code>`</code></p><pre
class="mb-0"><code>```<br/>code here<br/>```</code></pre></li></ul>
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: >-
<p>We do not allowed deleting tag with posts.</p><p>Please remove this tag
from the posts first.</p>
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 </1>- the open-source software that power Q&A
communities<br />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 <bold>{{mail}}</bold>.
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 <bold>{{mail}}</bold>. 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</1>
info_login: Already have an account? <1>Log in</1>
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 <strong>{{mail}}</strong>, 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 <strong>{{mail}}</strong>, 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</1>
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: >-
<p>Are you sure you want to add another answer?</p><p>You could use the
edit link to refine and improve your existing answer, instead.</p>
empty: Answer cannot be empty.
delete:
title: Delete this post
question: >-
We do not recommend <strong>deleting questions with answers</strong> because
doing so deprives future readers of this knowledge.</p><p>Repeated deletion
of answered questions can result in your account being blocked from asking.
Are you sure you wish to delete?
answer_accepted: >-
<p>We do not recommend <strong>deleting accepted answer</strong> because
doing so deprives future readers of this knowledge. </p> 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]</1> search withing a tag'
user: '<1>user:username</1> search by author'
answer: '<1>answers:0</1> unanswered questions'
score: '<1>score:3</1> posts with a 3+ score'
question: '<1>is:question</1> search questions'
is_answer: '<1>is:answer</1> search answers'
empty: We couldn't find anything. <br /> 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.<br />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: Cant 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</1> file manually in the
<1>/var/wwww/xxx/</1> directory and paste the following text into it.
info: 'After youve done that, click “Next” button.'
site_information: Site Information
admin_account: Admin Account
site_name:
label: Site Name
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</1>;
find it in the site menu.
good_luck: 'Have fun, and good luck!'
warn_title: Warning
warn_description: >-
The file <1>config.yaml</1> already exists. If you need to reset any of the
configuration items in this file, please delete it first.
install_now: You may try <1>installing now</1>.
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</1> file is incorrect or that contact with the database server could not be established. This could mean your hosts 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, well 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</1> 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'

6
i18n/i18n.yaml Normal file
View File

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

View File

@ -1,3 +1,5 @@
# The following fields are used for back-end
backend:
base: base:
success: success:
other: "Successo" other: "Successo"

View File

@ -1,3 +1,4 @@
backend:
base: base:
success: success:
other: "成功" other: "成功"
@ -170,3 +171,751 @@ notification:
other: "你的答案已被删除" other: "你的答案已被删除"
your_comment_was_deleted: your_comment_was_deleted:
other: "你的评论已被删除" other: "你的评论已被删除"
# The following fields are used for interface presentation(Front-end)
ui:
how_to_format:
title: 如何设定文本格式
description: >-
<ul class="mb-0"><li><p class="mb-2">添加链接:</p><pre
class="mb-2"><code>&lt;https://url.com&gt;<br/><br/>[标题](https://url.com)</code></pre></li><li><p
class="mb-2">段落之间使用空行分隔</p></li><li><p class="mb-2"><em>_斜体_</em> 或者
**<strong>粗体</strong>**</p></li><li><p class="mb-2">使用 4
个空格缩进代码</p></li><li><p
class="mb-2">在行首添加<code>&gt;</code>表示引用</p></li><li><p class="mb-2">反引号进行转义
<code>`像 _这样_`</code></p></li><li><p
class="mb-2">使用<code>```</code>创建代码块</p><pre class="mb-0"><code>```<br/>//
这是代码<br/>```</code></pre></li></ul>
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: <p>不允许删除有关联问题的标签。</p><p>请先从关联的问题中删除此标签的引用。</p>
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 </1>- the open-source software that power Q&A
communities<br />Made with love © 2022 Answer
upload_img:
name: 更改图片
loading: 加载中...
pic_auth_code:
title: 验证码
placeholder: 输入图片中的文字
msg:
empty: 不能为空
inactive:
first: '马上就好了!我们发送了一封激活邮件到 <bold>{{mail}}</bold>。请按照邮件中的说明激活您的帐户。'
info: 如果没有收到,请检查您的垃圾邮件文件夹。
another: '我们向您发送了另一封激活电子邮件,地址为 <bold>{{mail}}</bold>。它可能需要几分钟才能到达;请务必检查您的垃圾邮件文件夹。'
btn_name: 重新发送激活邮件
msg:
empty: 不能为空
login:
page_title: 欢迎来到 Answer
info_sign: 没有帐户?<1>注册</1>
info_login: 已经有一个帐户?<1>登录</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: '如无意外,你的邮箱 <strong>{{mail}}</strong> 将会收到一封重置密码的邮件,请根据指引重置你的密码。'
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>重置</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: <p>您确定要提交一个新的回答吗?</p><p>您可以直接编辑和改善您之前的回答的。</p>
empty: 回答内容不能为空。
delete:
title: 删除
question: >-
我们不建议<strong>删除有回答的帖子</strong>。因为这样做会使得后来的读者无法从该问题中获得帮助。</p><p>如果删除过多有回答的帖子,你的账号将会被禁止提问。你确定要删除吗?
answer_accepted: >-
<p>我们不建议<strong>删除被采纳的回答</strong>。因为这样做会使得后来的读者无法从该回答中获得帮助。</p>如果删除过多被采纳的回答,你的账号将会被禁止回答任何提问。你确定要删除吗?
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]</1> 在指定标签中搜索'
user: '<1>user:username</1> 根据作者搜索'
answer: '<1>answers:0</1> 搜索未回答的问题'
score: '<1>score:3</1> 评分 3 分或以上'
question: '<1>is:question</1> 只搜索问题'
is_answer: '<1>is:answer</1> 只搜索回答'
empty: 找不到任何相关的内容。<br /> 请尝试其他关键字,或者减少查找内容的长度。
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: 没有找到相关的内容。<br />试试看其他标签?
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>重置</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':

View File

@ -1,30 +1,64 @@
package conf package conf
import ( import (
"bytes"
"path/filepath"
"github.com/answerdev/answer/internal/base/data" "github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/base/server" "github.com/answerdev/answer/internal/base/server"
"github.com/answerdev/answer/internal/base/translator" "github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/cli"
"github.com/answerdev/answer/internal/router" "github.com/answerdev/answer/internal/router"
"github.com/answerdev/answer/internal/service/service_config" "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 // AllConfig all config
type AllConfig struct { type AllConfig struct {
Debug bool `json:"debug" mapstructure:"debug"` Debug bool `json:"debug" mapstructure:"debug" yaml:"debug"`
Data *Data `json:"data" mapstructure:"data"` Server *Server `json:"server" mapstructure:"server" yaml:"server"`
Server *Server `json:"server" mapstructure:"server"` Data *Data `json:"data" mapstructure:"data" yaml:"data"`
I18n *translator.I18n `json:"i18n" mapstructure:"i18n"` I18n *translator.I18n `json:"i18n" mapstructure:"i18n" yaml:"i18n"`
Swaggerui *router.SwaggerConfig `json:"swaggerui" mapstructure:"swaggerui"` ServiceConfig *service_config.ServiceConfig `json:"service_config" mapstructure:"service_config" yaml:"service_config"`
ServiceConfig *service_config.ServiceConfig `json:"service_config" mapstructure:"service_config"` Swaggerui *router.SwaggerConfig `json:"swaggerui" mapstructure:"swaggerui" yaml:"swaggerui"`
} }
// Server server config // Server server config
type Server struct { type Server struct {
HTTP *server.HTTP `json:"http" mapstructure:"http"` HTTP *server.HTTP `json:"http" mapstructure:"http" yaml:"http"`
} }
// Data data config // Data data config
type Data struct { type Data struct {
Database *data.Database `json:"database" mapstructure:"database"` Database *data.Database `json:"database" mapstructure:"database" yaml:"database"`
Cache *data.CacheConf `json:"cache" mapstructure:"cache"` 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())
} }

View File

@ -27,6 +27,8 @@ const (
// object TagID AnswerList // object TagID AnswerList
// key equal database's table name // key equal database's table name
var ( var (
Version string = ""
ObjectTypeStrMapping = map[string]int{ ObjectTypeStrMapping = map[string]int{
QuestionObjectType: 1, QuestionObjectType: 1,
AnswerObjectType: 2, AnswerObjectType: 2,
@ -47,3 +49,8 @@ var (
8: ReportObjectType, 8: ReportObjectType,
} }
) )
const (
SiteTypeGeneral = "general"
SiteTypeInterface = "interface"
)

View File

@ -2,14 +2,14 @@ package data
// Database database config // Database database config
type Database struct { type Database struct {
Driver string `json:"driver" mapstructure:"driver"` Driver string `json:"driver" mapstructure:"driver" yaml:"driver"`
Connection string `json:"connection" mapstructure:"connection"` Connection string `json:"connection" mapstructure:"connection" yaml:"connection"`
ConnMaxLifeTime int `json:"conn_max_life_time" mapstructure:"conn_max_life_time"` 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"` 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"` MaxIdleConn int `json:"max_idle_conn" mapstructure:"max_idle_conn" yaml:"max_idle_conn,omitempty"`
} }
// CacheConf cache // CacheConf cache
type CacheConf struct { type CacheConf struct {
FilePath string `json:"file_path" mapstructure:"file_path"` FilePath string `json:"file_path" mapstructure:"file_path" yaml:"file_path"`
} }

View File

@ -38,4 +38,9 @@ const (
LangNotFound = "error.lang.not_found" LangNotFound = "error.lang.not_found"
ReportHandleFailed = "error.report.handle_failed" ReportHandleFailed = "error.report.handle_failed"
ReportNotFound = "error.report.not_found" 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"
) )

View File

@ -2,5 +2,5 @@ package translator
// I18n i18n config // I18n i18n config
type I18n struct { type I18n struct {
BundleDir string `json:"bundle_dir" mapstructure:"bundle_dir"` BundleDir string `json:"bundle_dir" mapstructure:"bundle_dir" yaml:"bundle_dir"`
} }

View File

@ -1,17 +1,100 @@
package translator package translator
import ( import (
"fmt"
"os"
"path/filepath"
"github.com/google/wire" "github.com/google/wire"
myTran "github.com/segmentfault/pacman/contrib/i18n" myTran "github.com/segmentfault/pacman/contrib/i18n"
"github.com/segmentfault/pacman/i18n" "github.com/segmentfault/pacman/i18n"
"gopkg.in/yaml.v3"
) )
// ProviderSet is providers. // ProviderSet is providers.
var ProviderSet = wire.NewSet(NewTranslator) var ProviderSet = wire.NewSet(NewTranslator)
var GlobalTrans i18n.Translator 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 // NewTranslator new a translator
func NewTranslator(c *I18n) (tr i18n.Translator, err error) { 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 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
}

View File

@ -3,6 +3,7 @@ package validator
import ( import (
"errors" "errors"
"reflect" "reflect"
"strings"
"github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/translator" "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 { for _, fieldError := range valErrors {
errField = &ErrorField{ errField = &ErrorField{
Key: translator.GlobalTrans.Tr(m.Lang, fieldError.Field()), Key: fieldError.Field(),
Value: fieldError.Translate(m.Tran), 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)) 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 { type Checker interface {
Check() (errField *ErrorField, err error) 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
}

View File

@ -1,59 +1,71 @@
package cli package cli
import ( import (
"bufio"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"github.com/answerdev/answer/configs" "github.com/answerdev/answer/configs"
"github.com/answerdev/answer/i18n" "github.com/answerdev/answer/i18n"
"github.com/answerdev/answer/pkg/dir" "github.com/answerdev/answer/pkg/dir"
"github.com/answerdev/answer/pkg/writer"
) )
const ( const (
DefaultConfigFileName = "config.yaml" DefaultConfigFileName = "config.yaml"
DefaultCacheFileName = "cache.db"
) )
var ( var (
ConfigFilePath = "/conf/" ConfigFileDir = "/conf/"
UploadFilePath = "/upfiles/" UploadFilePath = "/uploads/"
I18nPath = "/i18n/" 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 // InstallAllInitialEnvironment install all initial environment
func InstallAllInitialEnvironment(dataDirPath string) { func InstallAllInitialEnvironment(dataDirPath string) {
ConfigFilePath = filepath.Join(dataDirPath, ConfigFilePath) FormatAllPath(dataDirPath)
UploadFilePath = filepath.Join(dataDirPath, UploadFilePath)
I18nPath = filepath.Join(dataDirPath, I18nPath)
installConfigFile()
installUploadDir() installUploadDir()
installI18nBundle() installI18nBundle()
fmt.Println("install all initial environment done") fmt.Println("install all initial environment done")
} }
func installConfigFile() { func InstallConfigFile(configFilePath string) error {
fmt.Println("[config-file] try to install...") if len(configFilePath) == 0 {
defaultConfigFile := filepath.Join(ConfigFilePath, DefaultConfigFileName) configFilePath = filepath.Join(ConfigFileDir, DefaultConfigFileName)
}
fmt.Println("[config-file] try to create at ", configFilePath)
// if config file already exists do nothing. // if config file already exists do nothing.
if CheckConfigFile(defaultConfigFile) { if CheckConfigFile(configFilePath) {
fmt.Printf("[config-file] %s already exists\n", defaultConfigFile) fmt.Printf("[config-file] %s already exists\n", configFilePath)
return 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()) 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()) 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") fmt.Printf("[config-file] install success\n")
return nil
} }
func installUploadDir() { func installUploadDir() {
@ -85,7 +97,7 @@ func installI18nBundle() {
continue continue
} }
fmt.Printf("[i18n] install %s bundle...\n", item.Name()) fmt.Printf("[i18n] install %s bundle...\n", item.Name())
err = writerFile(path, string(content)) err = writer.WriteFile(path, string(content))
if err != nil { if err != nil {
fmt.Printf("[i18n] install %s bundle fail: %s\n", item.Name(), err.Error()) fmt.Printf("[i18n] install %s bundle fail: %s\n", item.Name(), err.Error())
} else { } 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
}

View File

@ -1,7 +1,10 @@
package cli package cli
import ( import (
"fmt"
"github.com/answerdev/answer/internal/base/data" "github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/pkg/dir" "github.com/answerdev/answer/pkg/dir"
) )
@ -13,12 +16,40 @@ func CheckUploadDir() bool {
return dir.CheckDirExist(UploadFilePath) 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) db, err := data.NewDB(false, dataConf)
if err != nil { if err != nil {
fmt.Printf("connection database failed: %s\n", err)
return false return false
} }
if err = db.Ping(); err != nil { 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 false
} }
return true return true

View File

@ -9,6 +9,7 @@ import (
"github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema" "github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service" "github.com/answerdev/answer/internal/service"
"github.com/answerdev/answer/internal/service/dashboard"
"github.com/answerdev/answer/internal/service/rank" "github.com/answerdev/answer/internal/service/rank"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/errors"
@ -18,11 +19,19 @@ import (
type AnswerController struct { type AnswerController struct {
answerService *service.AnswerService answerService *service.AnswerService
rankService *rank.RankService rankService *rank.RankService
dashboardService *dashboard.DashboardService
} }
// NewAnswerController new controller // NewAnswerController new controller
func NewAnswerController(answerService *service.AnswerService, rankService *rank.RankService) *AnswerController { func NewAnswerController(answerService *service.AnswerService,
return &AnswerController{answerService: answerService, rankService: rankService} rankService *rank.RankService,
dashboardService *dashboard.DashboardService,
) *AnswerController {
return &AnswerController{
answerService: answerService,
rankService: rankService,
dashboardService: dashboardService,
}
} }
// RemoveAnswer delete answer // RemoveAnswer delete answer

View File

@ -20,4 +20,5 @@ var ProviderSetController = wire.NewSet(
NewReasonController, NewReasonController,
NewNotificationController, NewNotificationController,
NewSiteinfoController, NewSiteinfoController,
NewDashboardController,
) )

View File

@ -0,0 +1 @@
package controller

View File

@ -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,
})
}

View File

@ -4,18 +4,20 @@ import (
"encoding/json" "encoding/json"
"github.com/answerdev/answer/internal/base/handler" "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/gin-gonic/gin"
"github.com/segmentfault/pacman/i18n" "github.com/segmentfault/pacman/i18n"
) )
type LangController struct { type LangController struct {
translator i18n.Translator translator i18n.Translator
siteInfoService *siteinfo_common.SiteInfoCommonService
} }
// NewLangController new language controller. // NewLangController new language controller.
func NewLangController(tr i18n.Translator) *LangController { func NewLangController(tr i18n.Translator, siteInfoService *siteinfo_common.SiteInfoCommonService) *LangController {
return &LangController{translator: tr} return &LangController{translator: tr, siteInfoService: siteInfoService}
} }
// GetLangMapping get language config mapping // GetLangMapping get language config mapping
@ -33,15 +35,38 @@ func (u *LangController) GetLangMapping(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, resp) handler.HandleResponse(ctx, nil, resp)
} }
// GetLangOptions Get language options // GetAdminLangOptions Get language options
// @Summary Get language options // @Summary Get language options
// @Description Get language options // @Description Get language options
// @Security ApiKeyAuth
// @Tags Lang // @Tags Lang
// @Produce json // @Produce json
// @Success 200 {object} handler.RespBody{} // @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/language/options [get] // @Router /answer/api/v1/language/options [get]
// @Router /answer/admin/api/language/options [get] // @Router /answer/admin/api/language/options [get]
func (u *LangController) GetLangOptions(ctx *gin.Context) { func (u *LangController) GetAdminLangOptions(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, schema.GetLangOptions) 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)
} }

View File

@ -3,45 +3,36 @@ package controller
import ( import (
"github.com/answerdev/answer/internal/base/handler" "github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/schema" "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" "github.com/gin-gonic/gin"
) )
type SiteinfoController struct { type SiteinfoController struct {
siteInfoService *service.SiteInfoService siteInfoService *siteinfo_common.SiteInfoCommonService
} }
// NewSiteinfoController new siteinfo controller. // NewSiteinfoController new siteinfo controller.
func NewSiteinfoController(siteInfoService *service.SiteInfoService) *SiteinfoController { func NewSiteinfoController(siteInfoService *siteinfo_common.SiteInfoCommonService) *SiteinfoController {
return &SiteinfoController{ return &SiteinfoController{
siteInfoService: siteInfoService, siteInfoService: siteInfoService,
} }
} }
// GetInfo godoc // GetSiteInfo get site info
// @Summary Get siteinfo // @Summary get site info
// @Description Get siteinfo // @Description get site info
// @Tags site // @Tags site
// @Produce json // @Produce json
// @Success 200 {object} handler.RespBody{data=schema.SiteGeneralResp} // @Success 200 {object} handler.RespBody{data=schema.SiteGeneralResp}
// @Router /answer/api/v1/siteinfo [get] // @Router /answer/api/v1/siteinfo [get]
func (sc *SiteinfoController) GetInfo(ctx *gin.Context) { func (sc *SiteinfoController) GetSiteInfo(ctx *gin.Context) {
var ( var err error
resp = &schema.SiteInfoResp{} resp := &schema.SiteInfoResp{}
general schema.SiteGeneralResp resp.General, err = sc.siteInfoService.GetSiteGeneral(ctx)
face schema.SiteInterfaceResp
err error
)
general, err = sc.siteInfoService.GetSiteGeneral(ctx)
resp.General = &general
if err != nil { if err != nil {
handler.HandleResponse(ctx, err, resp) handler.HandleResponse(ctx, err, resp)
return return
} }
resp.Face, err = sc.siteInfoService.GetSiteInterface(ctx)
face, err = sc.siteInfoService.GetSiteInterface(ctx)
resp.Face = &face
handler.HandleResponse(ctx, err, resp) handler.HandleResponse(ctx, err, resp)
} }

View File

@ -89,22 +89,6 @@ func (uc *UserController) GetOtherUserInfoByUsername(ctx *gin.Context) {
handler.HandleResponse(ctx, err, resp) 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 // UserEmailLogin godoc
// @Summary UserEmailLogin // @Summary UserEmailLogin
// @Description UserEmailLogin // @Description UserEmailLogin
@ -373,6 +357,27 @@ func (uc *UserController) UserUpdateInfo(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil) 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 // UploadUserAvatar godoc
// @Summary UserUpdateInfo // @Summary UserUpdateInfo
// @Description UserUpdateInfo // @Description UserUpdateInfo
@ -490,6 +495,10 @@ func (uc *UserController) UserChangeEmailSendCode(ctx *gin.Context) {
req.UserID = middleware.GetLoginUserIDFromContext(ctx) req.UserID = middleware.GetLoginUserIDFromContext(ctx)
// If the user is not logged in, the api cannot be used. // 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 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) captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, schema.ActionRecordTypeEmail, ctx.ClientIP(), req.CaptchaID, req.CaptchaCode)
if !captchaPass { if !captchaPass {
@ -501,13 +510,15 @@ func (uc *UserController) UserChangeEmailSendCode(ctx *gin.Context) {
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), resp) handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), resp)
return return
} }
_, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeEmail, ctx.ClientIP())
if len(req.UserID) == 0 { resp, err := uc.userService.UserChangeEmailSendCode(ctx, req)
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) if err != nil {
if resp != nil {
resp.Value = translator.GlobalTrans.Tr(handler.GetLang(ctx), resp.Value)
}
handler.HandleResponse(ctx, err, resp)
return return
} }
_, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeEmail, ctx.ClientIP())
err := uc.userService.UserChangeEmailSendCode(ctx, req)
handler.HandleResponse(ctx, err, nil) handler.HandleResponse(ctx, err, nil)
} }

View File

@ -3,24 +3,24 @@ package controller_backyard
import ( import (
"github.com/answerdev/answer/internal/base/handler" "github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/schema" "github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service" "github.com/answerdev/answer/internal/service/siteinfo"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type SiteInfoController struct { type SiteInfoController struct {
siteInfoService *service.SiteInfoService siteInfoService *siteinfo.SiteInfoService
} }
// NewSiteInfoController new siteinfo controller. // NewSiteInfoController new siteinfo controller.
func NewSiteInfoController(siteInfoService *service.SiteInfoService) *SiteInfoController { func NewSiteInfoController(siteInfoService *siteinfo.SiteInfoService) *SiteInfoController {
return &SiteInfoController{ return &SiteInfoController{
siteInfoService: siteInfoService, siteInfoService: siteInfoService,
} }
} }
// GetGeneral godoc // GetGeneral get site general information
// @Summary Get siteinfo general // @Summary get site general information
// @Description Get siteinfo general // @Description get site general information
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Tags admin // @Tags admin
// @Produce json // @Produce json
@ -31,23 +31,22 @@ func (sc *SiteInfoController) GetGeneral(ctx *gin.Context) {
handler.HandleResponse(ctx, err, resp) handler.HandleResponse(ctx, err, resp)
} }
// GetInterface godoc // GetInterface get site interface
// @Summary Get siteinfo interface // @Summary get site interface
// @Description Get siteinfo interface // @Description get site interface
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Tags admin // @Tags admin
// @Produce json // @Produce json
// @Success 200 {object} handler.RespBody{data=schema.SiteInterfaceResp} // @Success 200 {object} handler.RespBody{data=schema.SiteInterfaceResp}
// @Router /answer/admin/api/siteinfo/interface [get] // @Router /answer/admin/api/siteinfo/interface [get]
// @Param data body schema.AddCommentReq true "general"
func (sc *SiteInfoController) GetInterface(ctx *gin.Context) { func (sc *SiteInfoController) GetInterface(ctx *gin.Context) {
resp, err := sc.siteInfoService.GetSiteInterface(ctx) resp, err := sc.siteInfoService.GetSiteInterface(ctx)
handler.HandleResponse(ctx, err, resp) handler.HandleResponse(ctx, err, resp)
} }
// UpdateGeneral godoc // UpdateGeneral update site general information
// @Summary Get siteinfo interface // @Summary update site general information
// @Description Get siteinfo interface // @Description update site general information
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Tags admin // @Tags admin
// @Produce json // @Produce json
@ -63,9 +62,9 @@ func (sc *SiteInfoController) UpdateGeneral(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil) handler.HandleResponse(ctx, err, nil)
} }
// UpdateInterface godoc // UpdateInterface update site interface
// @Summary Get siteinfo interface // @Summary update site info interface
// @Description Get siteinfo interface // @Description update site info interface
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Tags admin // @Tags admin
// @Produce json // @Produce json

View File

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

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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))
}

View File

@ -1,10 +1,12 @@
package migrations package migrations
import ( import (
"encoding/json"
"fmt" "fmt"
"github.com/answerdev/answer/internal/base/data" "github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/entity"
"golang.org/x/crypto/bcrypt"
"xorm.io/xorm" "xorm.io/xorm"
) )
@ -55,11 +57,6 @@ func InitDB(dataConf *data.Database) (err error) {
return fmt.Errorf("init admin user failed: %s", err) 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) err = initConfigTable(engine)
if err != nil { if err != nil {
return fmt.Errorf("init config table: %s", err) return fmt.Errorf("init config table: %s", err)
@ -82,12 +79,79 @@ func initAdminUser(engine *xorm.Engine) error {
return err 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{ _, err := engine.InsertOne(&entity.SiteInfo{
Type: "interface", Type: "interface",
Content: `{"logo":"","theme":"black","language":"en_US"}`, Content: string(interfaceDataBytes),
Status: 1, 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 return err
} }
@ -125,7 +189,7 @@ func initConfigTable(engine *xorm.Engine) error {
{ID: 30, Key: "answer.vote_up", Value: `0`}, {ID: 30, Key: "answer.vote_up", Value: `0`},
{ID: 31, Key: "answer.vote_up_cancel", Value: `0`}, {ID: 31, Key: "answer.vote_up_cancel", Value: `0`},
{ID: 32, Key: "question.follow", 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}}<br><br>\n\nClick the following link to confirm and activate your new account:<br>\n<a href='{{.RegisterUrl}}' target='_blank'>{{.RegisterUrl}}</a><br><br>\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}}].<br><br>\n\nIf it was not you, you can safely ignore this email.<br><br>\n\nClick the following link to choose a new password:<br>\n<a href='{{.PassResetUrl}}' target='_blank'>{{.PassResetUrl}}</a>\n","change_title":"[{{.SiteName}}] Confirm your new email address","change_body":"Confirm your new email address for {{.SiteName}} by clicking on the following link:<br><br>\n\n<a href='{{.ChangeEmailUrl}}' target='_blank'>{{.ChangeEmailUrl}}</a><br><br>\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}}<br><br>\n\nClick the following link to confirm and activate your new account:<br>\n<a href='{{.RegisterUrl}}' target='_blank'>{{.RegisterUrl}}</a><br><br>\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}}].<br><br>\n\nIf it was not you, you can safely ignore this email.<br><br>\n\nClick the following link to choose a new password:<br>\n<a href='{{.PassResetUrl}}' target='_blank'>{{.PassResetUrl}}</a>\n","change_title":"[{{.SiteName}}] Confirm your new email address","change_body":"Confirm your new email address for {{.SiteName}} by clicking on the following link:<br><br>\n\n<a href='{{.ChangeEmailUrl}}' target='_blank'>{{.ChangeEmailUrl}}</a><br><br>\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: 35, Key: "tag.follow", Value: `0`},
{ID: 36, Key: "rank.question.add", Value: `0`}, {ID: 36, Key: "rank.question.add", Value: `0`},
{ID: 37, Key: "rank.question.edit", Value: `0`}, {ID: 37, Key: "rank.question.edit", Value: `0`},

View File

@ -5,7 +5,6 @@ import (
"github.com/answerdev/answer/internal/base/data" "github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/entity"
"github.com/segmentfault/pacman/log"
"xorm.io/xorm" "xorm.io/xorm"
) )
@ -43,6 +42,7 @@ var noopMigration = func(_ *xorm.Engine) error { return nil }
var migrations = []Migration{ var migrations = []Migration{
// 0->1 // 0->1
NewMigration("this is first version, no operation", noopMigration), NewMigration("this is first version, no operation", noopMigration),
NewMigration("add user language", addUserLanguage),
} }
// GetCurrentDBVersion returns the current db version // GetCurrentDBVersion returns the current db version
@ -86,17 +86,17 @@ func Migrate(dataConf *data.Database) error {
expectedVersion := ExpectedVersion() expectedVersion := ExpectedVersion()
for currentDBVersion < 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) currentDBVersion, currentDBVersion+1, expectedVersion)
migrationFunc := migrations[currentDBVersion] 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 { 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 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 { 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 return err
} }
currentDBVersion++ currentDBVersion++

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

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

View File

@ -4,8 +4,10 @@ import (
"context" "context"
"github.com/answerdev/answer/internal/base/data" "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/entity"
"github.com/answerdev/answer/internal/service/activity_common" "github.com/answerdev/answer/internal/service/activity_common"
"github.com/segmentfault/pacman/errors"
) )
// VoteRepo activity repository // VoteRepo activity repository
@ -39,3 +41,12 @@ func (vr *VoteRepo) GetVoteStatus(ctx context.Context, objectID, userID string)
} }
return "" 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
}

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"time" "time"
"unicode" "unicode"
"xorm.io/builder" "xorm.io/builder"
"github.com/answerdev/answer/internal/base/constant" "github.com/answerdev/answer/internal/base/constant"
@ -102,6 +103,16 @@ func (ar *answerRepo) GetAnswer(ctx context.Context, id string) (
return 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 // GetAnswerList get answer list all
func (ar *answerRepo) GetAnswerList(ctx context.Context, answer *entity.Answer) (answerList []*entity.Answer, err error) { func (ar *answerRepo) GetAnswerList(ctx context.Context, answer *entity.Answer) (answerList []*entity.Answer, err error) {
answerList = make([]*entity.Answer, 0) answerList = make([]*entity.Answer, 0)

View File

@ -79,6 +79,15 @@ func (cr *commentRepo) GetComment(ctx context.Context, commentID string) (
return 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 // GetCommentPage get comment page
func (cr *commentRepo) GetCommentPage(ctx context.Context, commentQuery *comment.CommentQuery) ( func (cr *commentRepo) GetCommentPage(ctx context.Context, commentQuery *comment.CommentQuery) (
commentList []*entity.Comment, total int64, err error, commentList []*entity.Comment, total int64, err error,

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"time" "time"
"unicode" "unicode"
"xorm.io/builder" "xorm.io/builder"
"github.com/answerdev/answer/internal/base/constant" "github.com/answerdev/answer/internal/base/constant"
@ -162,6 +163,16 @@ func (qr *questionRepo) GetQuestionList(ctx context.Context, question *entity.Qu
return 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 // GetQuestionPage get question page
func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, question *entity.Question) (questionList []*entity.Question, total int64, err error) { 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) questionList = make([]*entity.Question, 0)

View File

@ -94,3 +94,12 @@ func (ar *reportRepo) UpdateByID(
} }
return 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
}

View File

@ -3,6 +3,7 @@ package search_common
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/answerdev/answer/pkg/htmltext"
"strings" "strings"
"time" "time"
@ -25,7 +26,7 @@ var (
"`question`.`id`", "`question`.`id`",
"`question`.`id` as `question_id`", "`question`.`id` as `question_id`",
"`title`", "`title`",
"`original_text`", "`parsed_text`",
"`question`.`created_at`", "`question`.`created_at`",
"`user_id`", "`user_id`",
"`vote_count`", "`vote_count`",
@ -38,7 +39,7 @@ var (
"`answer`.`id` as `id`", "`answer`.`id` as `id`",
"`question_id`", "`question_id`",
"`question`.`title` as `title`", "`question`.`title` as `title`",
"`answer`.`original_text` as `original_text`", "`answer`.`parsed_text` as `parsed_text`",
"`answer`.`created_at`", "`answer`.`created_at`",
"`answer`.`user_id` as `user_id`", "`answer`.`user_id` as `user_id`",
"`answer`.`vote_count` as `vote_count`", "`answer`.`vote_count` as `vote_count`",
@ -142,13 +143,22 @@ func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagID,
argsA = append(argsA, votes) argsA = append(argsA, votes)
} }
b = b.Union("all", ub) //b = b.Union("all", ub)
ubSQL, _, err := ub.ToSQL()
querySQL, _, err := builder.MySQL().Select("*").From(b, "t").OrderBy(sr.parseOrder(ctx, order)).Limit(size, page-1).ToSQL()
if err != nil { if err != nil {
return 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 { if err != nil {
return return
} }
@ -412,7 +422,7 @@ func (sr *searchRepo) parseResult(ctx context.Context, res []map[string][]byte)
object = schema.SearchObject{ object = schema.SearchObject{
ID: string(r["id"]), ID: string(r["id"]),
Title: string(r["title"]), Title: string(r["title"]),
Excerpt: cutOutParsedText(string(r["original_text"])), Excerpt: htmltext.FetchExcerpt(string(r["parsed_text"]), "...", 240),
CreatedAtParsed: tp.Unix(), CreatedAtParsed: tp.Unix(),
UserInfo: userInfo, UserInfo: userInfo,
Tags: tags, 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{}) { func addRelevanceField(searchFields, words, fields []string) (res []string, args []interface{}) {
relevanceRes := []string{} relevanceRes := []string{}
args = []interface{}{} args = []interface{}{}

View File

@ -101,6 +101,14 @@ func (ur *userRepo) UpdateEmail(ctx context.Context, userID, email string) (err
return 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 // UpdateInfo update user info
func (ur *userRepo) UpdateInfo(ctx context.Context, userInfo *entity.User) (err error) { func (ur *userRepo) UpdateInfo(ctx context.Context, userInfo *entity.User) (err error) {
_, err = ur.data.DB.Where("id = ?", userInfo.ID). _, err = ur.data.DB.Where("id = ?", userInfo.ID).
@ -149,3 +157,12 @@ func (ur *userRepo) GetByEmail(ctx context.Context, email string) (userInfo *ent
} }
return 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
}

View File

@ -27,6 +27,7 @@ type AnswerAPIRouter struct {
siteInfoController *controller_backyard.SiteInfoController siteInfoController *controller_backyard.SiteInfoController
siteinfoController *controller.SiteinfoController siteinfoController *controller.SiteinfoController
notificationController *controller.NotificationController notificationController *controller.NotificationController
dashboardController *controller.DashboardController
} }
func NewAnswerAPIRouter( func NewAnswerAPIRouter(
@ -50,6 +51,7 @@ func NewAnswerAPIRouter(
siteInfoController *controller_backyard.SiteInfoController, siteInfoController *controller_backyard.SiteInfoController,
siteinfoController *controller.SiteinfoController, siteinfoController *controller.SiteinfoController,
notificationController *controller.NotificationController, notificationController *controller.NotificationController,
dashboardController *controller.DashboardController,
) *AnswerAPIRouter { ) *AnswerAPIRouter {
return &AnswerAPIRouter{ return &AnswerAPIRouter{
@ -73,13 +75,14 @@ func NewAnswerAPIRouter(
siteInfoController: siteInfoController, siteInfoController: siteInfoController,
notificationController: notificationController, notificationController: notificationController,
siteinfoController: siteinfoController, siteinfoController: siteinfoController,
dashboardController: dashboardController,
} }
} }
func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
// i18n // i18n
r.GET("/language/config", a.langController.GetLangMapping) r.GET("/language/config", a.langController.GetLangMapping)
r.GET("/language/options", a.langController.GetLangOptions) r.GET("/language/options", a.langController.GetUserLangOptions)
// comment // comment
r.GET("/comment/page", a.commentController.GetCommentWithPage) r.GET("/comment/page", a.commentController.GetCommentWithPage)
@ -88,7 +91,6 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
// user // user
r.GET("/user/info", a.userController.GetUserInfoByUserID) r.GET("/user/info", a.userController.GetUserInfoByUserID)
r.GET("/user/status", a.userController.GetUserStatus)
r.GET("/user/action/record", a.userController.ActionRecord) r.GET("/user/action/record", a.userController.ActionRecord)
r.POST("/user/login/email", a.userController.UserEmailLogin) r.POST("/user/login/email", a.userController.UserEmailLogin)
r.POST("/user/register/email", a.userController.UserRegisterByEmail) 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) r.GET("/personal/rank/page", a.rankController.GetRankPersonalWithPage)
//siteinfo //siteinfo
r.GET("/siteinfo", a.siteinfoController.GetInfo) r.GET("/siteinfo", a.siteinfoController.GetSiteInfo)
} }
func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
@ -177,6 +179,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
// user // user
r.PUT("/user/password", a.userController.UserModifyPassWord) r.PUT("/user/password", a.userController.UserModifyPassWord)
r.PUT("/user/info", a.userController.UserUpdateInfo) 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/avatar/upload", a.userController.UploadUserAvatar)
r.POST("/user/post/file", a.userController.UploadUserPostFile) r.POST("/user/post/file", a.userController.UploadUserPostFile)
r.POST("/user/notice/set", a.userController.UserNoticeSet) 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) r.GET("/reasons", a.reasonController.Reasons)
// language // language
r.GET("/language/options", a.langController.GetLangOptions) r.GET("/language/options", a.langController.GetAdminLangOptions)
// theme // theme
r.GET("/theme/options", a.themeController.GetThemeOptions) 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.PUT("/siteinfo/interface", a.siteInfoController.UpdateInterface)
r.GET("/setting/smtp", a.siteInfoController.GetSMTPConfig) r.GET("/setting/smtp", a.siteInfoController.GetSMTPConfig)
r.PUT("/setting/smtp", a.siteInfoController.UpdateSMTPConfig) r.PUT("/setting/smtp", a.siteInfoController.UpdateSMTPConfig)
//dashboard
r.GET("/dashboard", a.dashboardController.DashboardInfo)
} }

View File

@ -2,8 +2,8 @@ package router
// SwaggerConfig struct describes configure for the Swagger API endpoint // SwaggerConfig struct describes configure for the Swagger API endpoint
type SwaggerConfig struct { type SwaggerConfig struct {
Show bool `json:"show"` Show bool `json:"show" mapstructure:"show" yaml:"show"`
Protocol string `json:"protocol"` Protocol string `json:"protocol" mapstructure:"protocol" yaml:"protocol"`
Host string `json:"host"` Host string `json:"host" mapstructure:"host" yaml:"host"`
Address string `json:"address"` Address string `json:"address" mapstructure:"address" yaml:"address"`
} }

View File

@ -78,6 +78,9 @@ func (a *UIRouter) Register(r *gin.Engine) {
filePath = UIRootFilePath + name filePath = UIRootFilePath + name
case "/manifest.json": case "/manifest.json":
filePath = UIRootFilePath + name filePath = UIRootFilePath + name
case "/install":
c.Redirect(http.StatusFound, "/")
return
default: default:
filePath = UIIndexFilePath filePath = UIIndexFilePath
c.Header("content-type", "text/html;charset=utf-8") c.Header("content-type", "text/html;charset=utf-8")

View File

@ -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"`
}

View File

@ -7,3 +7,5 @@ type ErrTypeData struct {
var ErrTypeModal = ErrTypeData{ErrType: "modal"} var ErrTypeModal = ErrTypeData{ErrType: "modal"}
var ErrTypeToast = ErrTypeData{ErrType: "toast"} var ErrTypeToast = ErrTypeData{ErrType: "toast"}
var ErrTypeAlert = ErrTypeData{ErrType: "alert"}

View File

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

View File

@ -1,17 +1,33 @@
package schema package schema
import (
"fmt"
"net/url"
)
// SiteGeneralReq site general request // SiteGeneralReq site general request
type SiteGeneralReq struct { type SiteGeneralReq struct {
Name string `validate:"required,gt=1,lte=128" comment:"site name" form:"name" json:"name"` Name string `validate:"required,gt=1,lte=128" form:"name" json:"name"`
ShortDescription string `validate:"required,gt=3,lte=255" comment:"short site description" form:"short_description" json:"short_description"` ShortDescription string `validate:"required,gt=3,lte=255" form:"short_description" json:"short_description"`
Description string `validate:"required,gt=3,lte=2000" comment:"site description" form:"description" json:"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 // SiteInterfaceReq site interface request
type SiteInterfaceReq struct { type SiteInterfaceReq struct {
Logo string `validate:"omitempty,gt=0,lte=256" comment:"logo" form:"logo" json:"logo"` Logo string `validate:"omitempty,gt=0,lte=256" form:"logo" json:"logo"`
Theme string `validate:"required,gt=1,lte=128" comment:"theme" form:"theme" json:"theme"` Theme string `validate:"required,gt=1,lte=128" form:"theme" json:"theme"`
Language string `validate:"required,gt=1,lte=128" comment:"interface language" form:"language" json:"language"` 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 // SiteGeneralResp site general response

View File

@ -62,6 +62,8 @@ type GetUserResp struct {
Location string `json:"location"` Location string `json:"location"`
// ip info // ip info
IPInfo string `json:"ip_info"` IPInfo string `json:"ip_info"`
// language
Language string `json:"language"`
// access token // access token
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
// is admin // is admin
@ -305,6 +307,14 @@ func (u *UpdateInfoRequest) Check() (errField *validator.ErrorField, err error)
return nil, nil 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 { type UserRetrievePassWordRequest struct {
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` // e_mail Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` // e_mail
CaptchaID string `json:"captcha_id" ` // captcha_id CaptchaID string `json:"captcha_id" ` // captcha_id

View File

@ -7,4 +7,5 @@ import (
// VoteRepo activity repository // VoteRepo activity repository
type VoteRepo interface { type VoteRepo interface {
GetVoteStatus(ctx context.Context, objectId, userId string) (status string) GetVoteStatus(ctx context.Context, objectId, userId string) (status string)
GetVoteCount(ctx context.Context, activityTypes []int) (count int64, err error)
} }

View File

@ -5,6 +5,7 @@ import (
"github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema" "github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/pkg/htmltext"
) )
type AnswerRepo interface { type AnswerRepo interface {
@ -20,6 +21,7 @@ type AnswerRepo interface {
SearchList(ctx context.Context, search *entity.AnswerSearch) ([]*entity.Answer, int64, error) SearchList(ctx context.Context, search *entity.AnswerSearch) ([]*entity.Answer, int64, error)
CmsSearchList(ctx context.Context, search *entity.CmsAnswerSearch) ([]*entity.Answer, int64, error) CmsSearchList(ctx context.Context, search *entity.CmsAnswerSearch) ([]*entity.Answer, int64, error)
UpdateAnswerStatus(ctx context.Context, answer *entity.Answer) (err error) UpdateAnswerStatus(ctx context.Context, answer *entity.Answer) (err error)
GetAnswerCount(ctx context.Context) (count int64, err error)
} }
// AnswerCommon user service // AnswerCommon user service
@ -74,11 +76,11 @@ func (as *AnswerCommon) AdminShowFormat(ctx context.Context, data *entity.Answer
info := schema.AdminAnswerInfo{} info := schema.AdminAnswerInfo{}
info.ID = data.ID info.ID = data.ID
info.QuestionID = data.QuestionID info.QuestionID = data.QuestionID
info.Description = data.ParsedText
info.Adopted = data.Adopted info.Adopted = data.Adopted
info.VoteCount = data.VoteCount info.VoteCount = data.VoteCount
info.CreateTime = data.CreatedAt.Unix() info.CreateTime = data.CreatedAt.Unix()
info.UpdateTime = data.UpdatedAt.Unix() info.UpdateTime = data.UpdatedAt.Unix()
info.UserID = data.UserID info.UserID = data.UserID
info.Description = htmltext.FetchExcerpt(data.ParsedText, "...", 240)
return &info return &info
} }

View File

@ -12,6 +12,7 @@ import (
// CommentCommonRepo comment repository // CommentCommonRepo comment repository
type CommentCommonRepo interface { type CommentCommonRepo interface {
GetComment(ctx context.Context, commentID string) (comment *entity.Comment, exist bool, err error) GetComment(ctx context.Context, commentID string) (comment *entity.Comment, exist bool, err error)
GetCommentCount(ctx context.Context) (count int64, err error)
} }
// CommentCommonService user service // CommentCommonService user service

View File

@ -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
}

View File

@ -0,0 +1 @@
package dashboard

View File

@ -8,6 +8,7 @@ import (
collectioncommon "github.com/answerdev/answer/internal/service/collection_common" collectioncommon "github.com/answerdev/answer/internal/service/collection_common"
"github.com/answerdev/answer/internal/service/comment" "github.com/answerdev/answer/internal/service/comment"
"github.com/answerdev/answer/internal/service/comment_common" "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/export"
"github.com/answerdev/answer/internal/service/follow" "github.com/answerdev/answer/internal/service/follow"
"github.com/answerdev/answer/internal/service/meta" "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_backyard"
"github.com/answerdev/answer/internal/service/report_handle_backyard" "github.com/answerdev/answer/internal/service/report_handle_backyard"
"github.com/answerdev/answer/internal/service/revision_common" "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" "github.com/answerdev/answer/internal/service/tag"
tagcommon "github.com/answerdev/answer/internal/service/tag_common" tagcommon "github.com/answerdev/answer/internal/service/tag_common"
"github.com/answerdev/answer/internal/service/uploader" "github.com/answerdev/answer/internal/service/uploader"
@ -61,8 +64,10 @@ var ProviderSetService = wire.NewSet(
report_backyard.NewReportBackyardService, report_backyard.NewReportBackyardService,
user_backyard.NewUserBackyardService, user_backyard.NewUserBackyardService,
reason.NewReasonService, reason.NewReasonService,
NewSiteInfoService, siteinfo_common.NewSiteInfoCommonService,
siteinfo.NewSiteInfoService,
notficationcommon.NewNotificationCommon, notficationcommon.NewNotificationCommon,
notification.NewNotificationService, notification.NewNotificationService,
activity.NewAnswerActivityService, activity.NewAnswerActivityService,
dashboard.NewDashboardService,
) )

View File

@ -38,6 +38,7 @@ type QuestionRepo interface {
UpdateLastAnswer(ctx context.Context, question *entity.Question) (err error) UpdateLastAnswer(ctx context.Context, question *entity.Question) (err error)
FindByID(ctx context.Context, id []string) (questionList []*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) CmsSearchList(ctx context.Context, search *schema.CmsQuestionSearch) ([]*entity.Question, int64, error)
GetQuestionCount(ctx context.Context) (count int64, err error)
} }
// QuestionCommon user service // QuestionCommon user service

View File

@ -2,9 +2,8 @@ package report_backyard
import ( import (
"context" "context"
"strings"
"github.com/answerdev/answer/internal/service/config" "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/pager"
"github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/base/reason"
@ -180,20 +179,20 @@ func (rs *ReportBackyardService) parseObject(ctx context.Context, resp *[]*schem
case "question": case "question":
r.QuestionID = questionId r.QuestionID = questionId
r.Title = question.Title r.Title = question.Title
r.Excerpt = rs.cutOutTagParsedText(question.OriginalText) r.Excerpt = htmltext.FetchExcerpt(question.ParsedText, "...", 240)
case "answer": case "answer":
r.QuestionID = questionId r.QuestionID = questionId
r.AnswerID = answerId r.AnswerID = answerId
r.Title = question.Title r.Title = question.Title
r.Excerpt = rs.cutOutTagParsedText(answer.OriginalText) r.Excerpt = htmltext.FetchExcerpt(answer.ParsedText, "...", 240)
case "comment": case "comment":
r.QuestionID = questionId r.QuestionID = questionId
r.AnswerID = answerId r.AnswerID = answerId
r.CommentID = commentId r.CommentID = commentId
r.Title = question.Title r.Title = question.Title
r.Excerpt = rs.cutOutTagParsedText(cmt.OriginalText) r.Excerpt = htmltext.FetchExcerpt(cmt.ParsedText, "...", 240)
} }
// parse reason // parse reason
@ -214,12 +213,3 @@ func (rs *ReportBackyardService) parseObject(ctx context.Context, resp *[]*schem
} }
resp = &res 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
}

View File

@ -2,6 +2,7 @@ package report_common
import ( import (
"context" "context"
"github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema" "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) 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) GetByID(ctx context.Context, id string) (report entity.Report, exist bool, err error)
UpdateByID(ctx context.Context, id string, handleData entity.Report) (err error) UpdateByID(ctx context.Context, id string, handleData entity.Report) (err error)
GetReportCount(ctx context.Context) (count int64, err error)
} }

View File

@ -1,7 +1,6 @@
package service_config package service_config
type ServiceConfig struct { type ServiceConfig struct {
SecretKey string `json:"secret_key" mapstructure:"secret_key"` SecretKey string `json:"secret_key" mapstructure:"secret_key" yaml:"secret_key"`
WebHost string `json:"web_host" mapstructure:"web_host"` UploadPath string `json:"upload_path" mapstructure:"upload_path" yaml:"upload_path"`
UploadPath string `json:"upload_path" mapstructure:"upload_path"`
} }

View File

@ -1,10 +1,12 @@
package service package siteinfo
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/reason" "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/entity"
"github.com/answerdev/answer/internal/schema" "github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/export" "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) { // GetSiteGeneral get site info general
var ( func (s *SiteInfoService) GetSiteGeneral(ctx context.Context) (resp *schema.SiteGeneralResp, err error) {
siteType = "general" siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeGeneral)
siteInfo *entity.SiteInfo if err != nil {
exist bool return nil, err
) }
resp = schema.SiteGeneralResp{}
siteInfo, exist, err = s.siteInfoRepo.GetByType(ctx, siteType)
if !exist { if !exist {
return return nil, errors.BadRequest(reason.SiteInfoNotFound)
} }
_ = json.Unmarshal([]byte(siteInfo.Content), &resp) resp = &schema.SiteGeneralResp{}
return _ = json.Unmarshal([]byte(siteInfo.Content), resp)
return resp, nil
} }
func (s *SiteInfoService) GetSiteInterface(ctx context.Context) (resp schema.SiteInterfaceResp, err error) { // GetSiteInterface get site info interface
var ( func (s *SiteInfoService) GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceResp, err error) {
siteType = "interface" siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeInterface)
siteInfo *entity.SiteInfo if err != nil {
exist bool return nil, err
) }
resp = schema.SiteInterfaceResp{}
siteInfo, exist, err = s.siteInfoRepo.GetByType(ctx, siteType)
if !exist { if !exist {
return return nil, errors.BadRequest(reason.SiteInfoNotFound)
} }
resp = &schema.SiteInterfaceResp{}
_ = json.Unmarshal([]byte(siteInfo.Content), &resp) _ = json.Unmarshal([]byte(siteInfo.Content), resp)
return return resp, nil
} }
func (s *SiteInfoService) SaveSiteGeneral(ctx context.Context, req schema.SiteGeneralReq) (err error) { func (s *SiteInfoService) SaveSiteGeneral(ctx context.Context, req schema.SiteGeneralReq) (err error) {
req.FormatSiteUrl()
var ( var (
siteType = "general" siteType = "general"
content []byte content []byte
@ -78,8 +76,7 @@ func (s *SiteInfoService) SaveSiteGeneral(ctx context.Context, req schema.SiteGe
func (s *SiteInfoService) SaveSiteInterface(ctx context.Context, req schema.SiteInterfaceReq) (err error) { func (s *SiteInfoService) SaveSiteInterface(ctx context.Context, req schema.SiteInterfaceReq) (err error) {
var ( var (
siteType = "interface" siteType = "interface"
themeExist, themeExist bool
langExist bool
content []byte content []byte
) )
@ -96,13 +93,7 @@ func (s *SiteInfoService) SaveSiteInterface(ctx context.Context, req schema.Site
} }
// check language // check language
for _, lang := range schema.GetLangOptions { if !translator.CheckLanguageIsValid(req.Language) {
if lang.Value == req.Language {
langExist = true
break
}
}
if !langExist {
err = errors.BadRequest(reason.LangNotFound) err = errors.BadRequest(reason.LangNotFound)
return return
} }

View File

@ -2,6 +2,7 @@ package siteinfo_common
import ( import (
"context" "context"
"github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/entity"
) )

View File

@ -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
}

View File

@ -3,9 +3,8 @@ package tag
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"strings"
"github.com/answerdev/answer/internal/service/revision_common" "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/pager"
"github.com/answerdev/answer/internal/base/reason" "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) resp := make([]*schema.GetTagPageResp, 0)
for _, tag := range tags { for _, tag := range tags {
excerpt := htmltext.FetchExcerpt(tag.ParsedText, "...", 240)
resp = append(resp, &schema.GetTagPageResp{ resp = append(resp, &schema.GetTagPageResp{
TagID: tag.ID, TagID: tag.ID,
SlugName: tag.SlugName, SlugName: tag.SlugName,
DisplayName: tag.DisplayName, DisplayName: tag.DisplayName,
OriginalText: cutOutTagParsedText(tag.OriginalText), OriginalText: excerpt,
ParsedText: cutOutTagParsedText(tag.ParsedText), ParsedText: excerpt,
FollowCount: tag.FollowCount, FollowCount: tag.FollowCount,
QuestionCount: tag.QuestionCount, QuestionCount: tag.QuestionCount,
IsFollower: ts.checkTagIsFollow(ctx, req.UserID, tag.ID), IsFollower: ts.checkTagIsFollow(ctx, req.UserID, tag.ID),
@ -371,12 +371,3 @@ func (ts *TagService) checkTagIsFollow(ctx context.Context, userID, tagID string
} }
return followed 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
}

View File

@ -12,6 +12,7 @@ import (
"github.com/answerdev/answer/internal/base/reason" "github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/service/service_config" "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/dir"
"github.com/answerdev/answer/pkg/uid" "github.com/answerdev/answer/pkg/uid"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
@ -28,10 +29,12 @@ const (
// UploaderService user service // UploaderService user service
type UploaderService struct { type UploaderService struct {
serviceConfig *service_config.ServiceConfig serviceConfig *service_config.ServiceConfig
siteInfoService *siteinfo_common.SiteInfoCommonService
} }
// NewUploaderService new upload service // 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)) err := dir.CreateDirIfNotExist(filepath.Join(serviceConfig.UploadPath, avatarSubPath))
if err != nil { if err != nil {
panic(err) panic(err)
@ -42,6 +45,7 @@ func NewUploaderService(serviceConfig *service_config.ServiceConfig) *UploaderSe
} }
return &UploaderService{ 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) ( func (us *UploaderService) uploadFile(ctx *gin.Context, file *multipart.FileHeader, fileSubPath string) (
url string, err error) { url string, err error) {
siteGeneral, err := us.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
return "", err
}
filePath := path.Join(us.serviceConfig.UploadPath, fileSubPath) filePath := path.Join(us.serviceConfig.UploadPath, fileSubPath)
if err := ctx.SaveUploadedFile(file, filePath); err != nil { if err := ctx.SaveUploadedFile(file, filePath); err != nil {
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() 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 return url, nil
} }

View File

@ -15,12 +15,14 @@ type UserRepo interface {
UpdateEmailStatus(ctx context.Context, userID string, emailStatus int) error UpdateEmailStatus(ctx context.Context, userID string, emailStatus int) error
UpdateNoticeStatus(ctx context.Context, userID string, noticeStatus int) error UpdateNoticeStatus(ctx context.Context, userID string, noticeStatus int) error
UpdateEmail(ctx context.Context, userID, email string) 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 UpdatePass(ctx context.Context, userID, pass string) error
UpdateInfo(ctx context.Context, userInfo *entity.User) (err error) UpdateInfo(ctx context.Context, userInfo *entity.User) (err error)
GetByUserID(ctx context.Context, userID string) (userInfo *entity.User, exist bool, err error) GetByUserID(ctx context.Context, userID string) (userInfo *entity.User, exist bool, err error)
BatchGetByID(ctx context.Context, ids []string) ([]*entity.User, error) BatchGetByID(ctx context.Context, ids []string) ([]*entity.User, error)
GetByUsername(ctx context.Context, username string) (userInfo *entity.User, exist bool, err 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) GetByEmail(ctx context.Context, email string) (userInfo *entity.User, exist bool, err error)
GetUserCount(ctx context.Context) (count int64, err error)
} }
// UserCommon user service // UserCommon user service

View File

@ -11,12 +11,14 @@ import (
"github.com/Chain-Zhang/pinyin" "github.com/Chain-Zhang/pinyin"
"github.com/answerdev/answer/internal/base/reason" "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/entity"
"github.com/answerdev/answer/internal/schema" "github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/activity" "github.com/answerdev/answer/internal/service/activity"
"github.com/answerdev/answer/internal/service/auth" "github.com/answerdev/answer/internal/service/auth"
"github.com/answerdev/answer/internal/service/export" "github.com/answerdev/answer/internal/service/export"
"github.com/answerdev/answer/internal/service/service_config" "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" usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/answerdev/answer/pkg/checker" "github.com/answerdev/answer/pkg/checker"
"github.com/google/uuid" "github.com/google/uuid"
@ -34,6 +36,7 @@ type UserService struct {
serviceConfig *service_config.ServiceConfig serviceConfig *service_config.ServiceConfig
emailService *export.EmailService emailService *export.EmailService
authService *auth.AuthService authService *auth.AuthService
siteInfoService *siteinfo_common.SiteInfoCommonService
} }
func NewUserService(userRepo usercommon.UserRepo, func NewUserService(userRepo usercommon.UserRepo,
@ -41,6 +44,7 @@ func NewUserService(userRepo usercommon.UserRepo,
emailService *export.EmailService, emailService *export.EmailService,
authService *auth.AuthService, authService *auth.AuthService,
serviceConfig *service_config.ServiceConfig, serviceConfig *service_config.ServiceConfig,
siteInfoService *siteinfo_common.SiteInfoCommonService,
) *UserService { ) *UserService {
return &UserService{ return &UserService{
userRepo: userRepo, userRepo: userRepo,
@ -48,6 +52,7 @@ func NewUserService(userRepo usercommon.UserRepo,
emailService: emailService, emailService: emailService,
serviceConfig: serviceConfig, serviceConfig: serviceConfig,
authService: authService, authService: authService,
siteInfoService: siteInfoService,
} }
} }
@ -66,35 +71,6 @@ func (us *UserService) GetUserInfoByUserID(ctx context.Context, token, userID st
return resp, nil 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) ( func (us *UserService) GetOtherUserInfoByUsername(ctx context.Context, username string) (
resp *schema.GetOtherUserInfoResp, err error, resp *schema.GetOtherUserInfoResp, err error,
) { ) {
@ -168,7 +144,7 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet
UserID: userInfo.ID, UserID: userInfo.ID,
} }
code := uuid.NewString() 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) title, body, err := us.emailService.PassResetTemplate(ctx, verifyEmailURL)
if err != nil { if err != nil {
return "", err return "", err
@ -283,6 +259,18 @@ func (us *UserService) UserEmailHas(ctx context.Context, email string) (bool, er
return has, nil 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 // UserRegisterByEmail user register
func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo *schema.UserRegisterReq) ( func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo *schema.UserRegisterReq) (
resp *schema.GetUserResp, err error, resp *schema.GetUserResp, err error,
@ -320,7 +308,7 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
UserID: userInfo.ID, UserID: userInfo.ID,
} }
code := uuid.NewString() 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) title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL)
if err != nil { if err != nil {
return nil, err return nil, err
@ -363,7 +351,7 @@ func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) e
UserID: userInfo.ID, UserID: userInfo.ID,
} }
code := uuid.NewString() 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) title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL)
if err != nil { if err != nil {
return err return err
@ -489,21 +477,26 @@ func (us *UserService) encryptPassword(ctx context.Context, Pass string) (string
} }
// UserChangeEmailSendCode user change email verification // 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) userInfo, exist, err := us.userRepo.GetByUserID(ctx, req.UserID)
if err != nil { if err != nil {
return err return nil, err
} }
if !exist { if !exist {
return errors.BadRequest(reason.UserNotFound) return nil, errors.BadRequest(reason.UserNotFound)
} }
_, exist, err = us.userRepo.GetByEmail(ctx, req.Email) _, exist, err = us.userRepo.GetByEmail(ctx, req.Email)
if err != nil { if err != nil {
return err return nil, err
} }
if exist { 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{ data := &schema.EmailCodeContent{
@ -512,19 +505,19 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
} }
code := uuid.NewString() code := uuid.NewString()
var title, body string 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 { if userInfo.MailStatus == entity.EmailStatusToBeVerified {
title, body, err = us.emailService.RegisterTemplate(ctx, verifyEmailURL) title, body, err = us.emailService.RegisterTemplate(ctx, verifyEmailURL)
} else { } else {
title, body, err = us.emailService.ChangeEmailTemplate(ctx, verifyEmailURL) title, body, err = us.emailService.ChangeEmailTemplate(ctx, verifyEmailURL)
} }
if err != nil { if err != nil {
return err return nil, err
} }
log.Infof("send email confirmation %s", verifyEmailURL) log.Infof("send email confirmation %s", verifyEmailURL)
go us.emailService.Send(context.Background(), req.Email, title, body, code, data.ToJSONString()) go us.emailService.Send(context.Background(), req.Email, title, body, code, data.ToJSONString())
return nil return nil, nil
} }
// UserChangeEmailVerify user change email verify code // UserChangeEmailVerify user change email verify code
@ -560,3 +553,13 @@ func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string
} }
return nil 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
}

View File

@ -1,6 +1,10 @@
package dir package dir
import "os" import (
"fmt"
"os"
"path/filepath"
)
func CreateDirIfNotExist(path string) error { func CreateDirIfNotExist(path string) error {
return os.MkdirAll(path, os.ModePerm) return os.MkdirAll(path, os.ModePerm)
@ -15,3 +19,32 @@ func CheckFileExist(path string) bool {
f, err := os.Stat(path) f, err := os.Stat(path)
return err == nil && !f.IsDir() 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))
}
}

60
pkg/htmltext/htmltext.go Normal file
View File

@ -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.*?[^<]>(.*)?<\/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
}

View File

@ -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("<p>hello<pre>var a = \"good\"</pre></p>")
assert.Equal(t, expected, clearedText)
// test link clear text
expected = "hello [example.com]"
clearedText = ClearText("<p>hello <a href=\"http://example.com/\">example.com</a></p>")
assert.Equal(t, expected, clearedText)
clearedText = ClearText("<p>hello<a href=\"https://example.com/\">example.com</a></p>")
assert.Equal(t, expected, clearedText)
expected = "hello world"
clearedText = ClearText("<div> hello</div>\n<div>world</div>")
assert.Equal(t, expected, clearedText)
}
func TestFetchExcerpt(t *testing.T) {
var (
expected,
text string
)
// test english string
expected = "hello..."
text = FetchExcerpt("<p>hello world</p>", "...", 5)
assert.Equal(t, expected, text)
// test mixed string
expected = "hello你好..."
text = FetchExcerpt("<p>hello你好world</p>", "...", 7)
assert.Equal(t, expected, text)
// test mixed string with emoticon
expected = "hello你好😂..."
text = FetchExcerpt("<p>hello你好😂world</p>", "...", 8)
assert.Equal(t, expected, text)
}

31
pkg/writer/writer.go Normal file
View File

@ -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
}

View File

@ -1,3 +1,4 @@
#!/bin/bash #!/bin/bash
/usr/bin/answer init /usr/bin/answer init
/usr/bin/answer run -c /data/conf/config.yaml /usr/bin/answer upgrade
/usr/bin/answer run -C /data/

View File

@ -1,4 +1,5 @@
module.exports = { module.exports = {
root: true,
env: { env: {
browser: true, browser: true,
es2021: true, es2021: true,
@ -19,7 +20,8 @@ module.exports = {
}, },
ecmaVersion: 'latest', ecmaVersion: 'latest',
sourceType: 'module', sourceType: 'module',
project: './tsconfig.json', tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
}, },
plugins: ['react', '@typescript-eslint'], plugins: ['react', '@typescript-eslint'],
rules: { rules: {
@ -64,7 +66,7 @@ module.exports = {
position: 'before', position: 'before',
}, },
{ {
pattern: '@answer/**', pattern: '@/**',
group: 'internal', group: 'internal',
}, },
{ {

View File

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

View File

@ -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 = { module.exports = {
webpack: function(config, env) { webpack: function(config, env) {
if (env === 'production') { if (env === "production") {
config.output.publicPath = process.env.REACT_APP_PUBLIC_PATH; config.output.publicPath = process.env.REACT_APP_PUBLIC_PATH;
} }
config.resolve.alias = {
...config.resolve.alias, addWebpackAlias({
'@': path.resolve(__dirname, 'src'), ["@"]: path.resolve(__dirname, "src"),
'@answer/pages': path.resolve(__dirname, 'src/pages'), "@i18n": i18nPath
'@answer/components': path.resolve(__dirname, 'src/components'), })(config);
'@answer/stores': path.resolve(__dirname, 'src/stores'),
'@answer/hooks': path.resolve(__dirname, 'src/hooks'), addWebpackModuleRule({
'@answer/utils': path.resolve(__dirname, 'src/utils'), test: /\.ya?ml$/,
'@answer/common': path.resolve(__dirname, 'src/common'), use: "yaml-loader"
'@answer/api': path.resolve(__dirname, 'src/services/api'), })(config);
};
// add i18n dir to ModuleScopePlugin allowedPaths
const moduleScopePlugin = config.resolve.plugins.find(_ => _.constructor.name === "ModuleScopePlugin");
if (moduleScopePlugin) {
moduleScopePlugin.allowedPaths.push(i18nPath);
}
return config; return config;
}, },
devServer: function(configFunction) { devServer: function(configFunction) {
return function(proxy, allowedHost) { return function(proxy, allowedHost) {
const config = configFunction(proxy, allowedHost); const config = configFunction(proxy, allowedHost);
config.proxy = { config.proxy = {
'/answer': { "/answer": {
target: 'http://10.0.10.98:2060', target: "http://10.0.10.98:2060",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false
}, },
"/installation": {
target: "http://10.0.10.98:2060",
changeOrigin: true,
secure: false
}
}; };
return config; return config;
}; };
}, }
}; };

View File

@ -10,12 +10,12 @@
"build:prod": "env-cmd -f .env.production react-app-rewired build", "build:prod": "env-cmd -f .env.production react-app-rewired build",
"build": "env-cmd -f .env react-app-rewired build", "build": "env-cmd -f .env react-app-rewired build",
"test": "react-app-rewired test", "test": "react-app-rewired test",
"eject": "react-scripts eject",
"lint": "eslint . --cache --fix --ext .ts,.tsx", "lint": "eslint . --cache --fix --ext .ts,.tsx",
"prepare": "cd .. && husky install", "prepare": "cd .. && husky install",
"cz": "cz", "cz": "cz",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", "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": { "config": {
"commitizen": { "commitizen": {
@ -101,7 +101,8 @@
"sass": "^1.54.4", "sass": "^1.54.4",
"tsconfig-paths-webpack-plugin": "^4.0.0", "tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "*", "typescript": "*",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4",
"yaml-loader": "^0.8.0"
}, },
"packageManager": "pnpm@7.9.5", "packageManager": "pnpm@7.9.5",
"engines": { "engines": {

View File

@ -77,6 +77,7 @@ specifiers:
tsconfig-paths-webpack-plugin: ^4.0.0 tsconfig-paths-webpack-plugin: ^4.0.0
typescript: '*' typescript: '*'
web-vitals: ^2.1.4 web-vitals: ^2.1.4
yaml-loader: ^0.8.0
zustand: ^4.1.1 zustand: ^4.1.1
dependencies: dependencies:
@ -159,6 +160,7 @@ devDependencies:
tsconfig-paths-webpack-plugin: 4.0.0 tsconfig-paths-webpack-plugin: 4.0.0
typescript: 4.8.3 typescript: 4.8.3
web-vitals: 2.1.4 web-vitals: 2.1.4
yaml-loader: 0.8.0
packages: packages:
@ -7040,6 +7042,10 @@ packages:
filelist: 1.0.4 filelist: 1.0.4
minimatch: 3.1.2 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: /jest-changed-files/27.5.1:
resolution: {integrity: sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==} resolution: {integrity: sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@ -11682,6 +11688,15 @@ packages:
/yallist/4.0.0: /yallist/4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} 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: /yaml/1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}

5
ui/scripts/preinstall.js Normal file
View File

@ -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)
}

View File

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

View File

@ -1,9 +1,10 @@
export const LOGIN_NEED_BACK = [ export const DEFAULT_LANG = 'en_US';
'/users/login', export const CURRENT_LANG_STORAGE_KEY = '_a_lang_';
'/users/register', export const LANG_RESOURCE_STORAGE_KEY = '_a_lang_r_';
'/users/account-recovery', export const LOGGED_USER_STORAGE_KEY = '_a_lui_';
'/users/password-reset', 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 = { export const ADMIN_LIST_STATUS = {
// normal; // normal;
@ -56,3 +57,494 @@ export const ADMIN_NAV_MENUS = [
child: [{ name: 'general' }, { name: 'interface' }, { name: 'smtp' }], 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';

View File

@ -109,16 +109,19 @@ export interface UserInfoBase {
*/ */
status?: string; status?: string;
/** roles */ /** roles */
is_admin?: true; is_admin?: boolean;
} }
export interface UserInfoRes extends UserInfoBase { export interface UserInfoRes extends UserInfoBase {
bio: string; bio: string;
bio_html: string; bio_html: string;
create_time?: string; create_time?: string;
/** value = 1 active; value = 2 inactivated /**
* value = 1 active;
* value = 2 inactivated
*/ */
mail_status: number; mail_status: number;
language: string;
e_mail?: string; e_mail?: string;
[prop: string]: any; [prop: string]: any;
} }
@ -228,6 +231,7 @@ export type AdminContentsFilterBy = 'normal' | 'closed' | 'deleted';
export interface AdminContentsReq extends Paging { export interface AdminContentsReq extends Paging {
status: AdminContentsFilterBy; status: AdminContentsFilterBy;
query?: string;
} }
/** /**
@ -257,12 +261,15 @@ export interface AdminSettingsGeneral {
name: string; name: string;
short_description: string; short_description: string;
description: string; description: string;
site_url: string;
contact_email: string;
} }
export interface AdminSettingsInterface { export interface AdminSettingsInterface {
logo: string; logo: string;
language: string; language: string;
theme: string; theme: string;
time_zone?: string;
} }
export interface AdminSettingsSmtp { export interface AdminSettingsSmtp {
@ -321,3 +328,24 @@ export interface SearchResItem {
export interface SearchRes extends ListResult<SearchResItem> { export interface SearchRes extends ListResult<SearchResItem> {
extra: any; 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;
};
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,10 +37,10 @@ const Index: FC<Props> = ({ time, preFix, className }) => {
between < 3600 * 24 * 366 && between < 3600 * 24 * 366 &&
dayjs.unix(from).format('YYYY') === dayjs.unix(now).format('YYYY') 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) { if (!time) {
@ -50,8 +50,8 @@ const Index: FC<Props> = ({ time, preFix, className }) => {
return ( return (
<time <time
className={classNames('', className)} className={classNames('', className)}
dateTime={dayjs.unix(time).toISOString()} dateTime={dayjs.unix(time).tz().toISOString()}
title={dayjs.unix(time).format(t('dates.long_date_with_time'))}> title={dayjs.unix(time).tz().format(t('dates.long_date_with_time'))}>
{preFix ? `${preFix} ` : ''} {preFix ? `${preFix} ` : ''}
{formatTime(time)} {formatTime(time)}
</time> </time>

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