Merge branch 'feat/1.0.4/comment' into feat/plugin/connector/github

# Conflicts:
#	internal/migrations/migrations.go
#	internal/migrations/v6.go
#	internal/service/user_common/user.go
#	internal/service/user_service.go
#	pkg/checker/reserved_username.go
#	ui/build/index.html
This commit is contained in:
LinkinStar 2023-02-01 17:00:01 +08:00
commit 6a8d5e5984
146 changed files with 4033 additions and 3676 deletions

41
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: "Goreleaser"
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build-and-push:
runs-on: [self-hosted, linux]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Node Build
run: make install-ui-packages ui
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.19
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v4
with:
distribution: goreleaser
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v3
with:
name: answer
path: ./dist/*

1
.gitignore vendored
View File

@ -15,7 +15,6 @@
/configs/config-dev.yaml
/go.work*
/logs
/ui/build
/ui/node_modules
/vendor
Thumbs*.db

View File

@ -2,7 +2,7 @@ env:
- GO11MODULE=on
- GO111MODULE=on
- GOPROXY=https://goproxy.io
- CGO_ENABLED=1
- CGO_ENABLED=0
before:
hooks:
@ -39,8 +39,8 @@ builds:
goos:
- linux
goarch:
- arm64
- id: build-arm7
- arm64
- id: build-arm7
main: ./cmd/answer/.
binary: answer
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
@ -64,7 +64,7 @@ builds:
- darwin
goarch:
- arm64
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
flags: -v
- id: build-darwin-amd64
main: ./cmd/answer/.
@ -76,7 +76,7 @@ builds:
- darwin
goarch:
- amd64
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
ldflags: -s -w -X main.Version={{.Version}} -X main.Revision={{.ShortCommit}} -X main.Time={{.Date}} -X main.BuildUser=goreleaser
flags: -v
@ -96,9 +96,9 @@ changelog:
- '^test:'
# sudo apt-get install build-essential
# sudo apt-get install build-essential
# sudo apt-get install gcc-multilib g++-multilib
# sudo apt-get install gcc-mingw-w64
# sudo apt-get -y install gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
# sudo apt-get install clang llvm
# goreleaser release --snapshot --rm-dist
# goreleaser release --snapshot --rm-dist

View File

@ -1,8 +0,0 @@
# Contributing to answer
## Coding and documentation Style
To be developed.
## Submitting Modifications
To be developed.

View File

@ -1,69 +0,0 @@
# Answer installation guide
## Environment Preparation
- Memory >= 512M
- If using MySQL version >= 5.7
## Installing with docker
### Step 1: Start the project with the docker command
```bash
docker run -d -p 9080:80 -v answer-data:/data --name answer answerdev/answer:latest
```
### Step 2: Visit the installation url
[http://127.0.0.1:9080/install](http://127.0.0.1:9080/install)
After selecting the language click next to select the appropriate database, if you just want to experience it currently, it is recommended to select sqlite as the database directly, as shown below
![install-database](docs/img/install-database.png)
Then click next to create the configuration file, click next to enter the basic website information and administrator information, as shown below
![install- site-info](docs/img/install-site-info.png)
Click Next to complete the installation
### Step 3: After installation, visit the project path to start using
[http://127.0.0.1:9080/](http://127.0.0.1:9080/)
Login with the administrator username and password you just created.
## Installing with docker-compose
### Step 1: Start the project with the docker-compose command
```bash
mkdir answer && cd answer
wget https://raw.githubusercontent.com/answerdev/answer/main/docker-compose.yaml
docker-compose up
```
### Step 2: Visit the installation url
[http://127.0.0.1:9080/install](http://127.0.0.1:9080/install)
The exact configuration is the same as for docker use
### Step 3: After installation, visit home page
[http://127.0.0.1:9080/](http://127.0.0.1:9080/)
## Install with binary
### Step 1: Download the binaries
[https://github.com/answerdev/answer/releases](https://github.com/answerdev/answer/releases)
Download the version you need for your current system
### Step 2: Install using command line
> The following command -C specifies the data directory required for answer, you can modify it as you see fit
```bash
. /answer init -C . /answer-data/
```
Then visit: [http://127.0.0.1:9080/install](http://127.0.0.1:9080/install) to install, the configuration is the same as using docker installation
### Step 3: Start with command line
After the installation is complete, the program will exit, so use the command to start the project formally
```bash
. /answer run -C . /answer-data/
```
After normal startup you can access [http://127.0.0.1:9080/](http://127.0.0.1:9080/) to log in using the administrator username password specified during installation
## Installation FAQ
- Having trouble reinstalling using docker? The default command we give is to use `answer-data` to name the volume, so if you don't need the original data again, please delete it voluntarily `docker volume rm answer-data`

View File

@ -1,68 +0,0 @@
# Answer 安装指引
## 环境准备
- 内存 >= 512M
- 如果使用 MySQL 版本 >= 5.7
## 使用 docker 安装
### 步骤 1: 使用 docker 命令启动项目
```bash
docker run -d -p 9080:80 -v answer-data:/data --name answer answerdev/answer:latest
```
### 步骤 2: 访问安装路径进行项目安装
[http://127.0.0.1:9080/install](http://127.0.0.1:9080/install)
选择语言后点击下一步选择合适的数据库,如果当前只是想体验,建议直接选择 sqlite 作为数据库,如下图所示
![install-database](docs/img/install-database.png)
然后点击下一步会进行配置文件创建等操作,点击下一步输入网站基本信息和管理员信息,如下图所示
![install-site-info](docs/img/install-site-info.png)
点击下一步即可安装完成
### 步骤 3安装完成后访问项目路径开始使用
[http://127.0.0.1:9080/](http://127.0.0.1:9080/)
使用刚才创建的管理员用户名密码即可登录。
## 使用 docker-compose 安装
### 步骤 1: 使用 docker-compose 命令启动项目
```bash
mkdir answer && cd answer
wget https://raw.githubusercontent.com/answerdev/answer/main/docker-compose.yaml
docker-compose up
```
### 步骤 2: 访问安装路径进行项目安装
[http://127.0.0.1:9080/install](http://127.0.0.1:9080/install)
具体配置与 docker 使用时相同
### 步骤 3安装完成后访问项目路径开始使用
[http://127.0.0.1:9080/](http://127.0.0.1:9080/)
## 使用 二进制 安装
### 步骤 1: 下载二进制文件
[https://github.com/answerdev/answer/releases](https://github.com/answerdev/answer/releases)
请下载您当下系统所需要的对应版本
### 步骤 2: 使用命令行安装
> 以下命令中 -C 指定的是 answer 所需的数据目录,您可以根据实际需要进行修改
```bash
./answer init -C ./answer-data/
```
然后访问:[http://127.0.0.1:9080/install](http://127.0.0.1:9080/install) 进行安装,具体配置与使用 docker 安装相同
### 步骤 3: 使用命令行启动
安装完成之后程序会退出,请使用命令正式启动项目
```bash
./answer run -C ./answer-data/
```
正常启动后可以访问 [http://127.0.0.1:9080/](http://127.0.0.1:9080/) 使用安装时指定的管理员用户名密码进行登录
## 安装常见问题
- 使用 docker 重新安装遇到问题?默认我们给出的命令是使用 `answer-data` 命名卷,所以如果重新不需要原来的数据,请主动进行删除 `docker volume rm answer-data`

View File

@ -1,30 +1,30 @@
.PHONY: build clean ui
VERSION=1.0.1
VERSION=1.0.3
BIN=answer
DIR_SRC=./cmd/answer
DOCKER_CMD=docker
#GO_ENV=CGO_ENABLED=0
GO_ENV=CGO_ENABLED=0 GO111MODULE=on
Revision=$(shell git rev-parse --short HEAD)
GO_FLAGS=-ldflags="-X answercmd.Version=$(VERSION) -X 'answercmd.Revision=$(Revision)' -X 'answercmd.Time=`date`' -extldflags -static"
GO=$(GO_ENV) $(shell which go)
build:
@$(GO_ENV) $(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC)
build: generate
@$(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC)
# https://dev.to/thewraven/universal-macos-binaries-with-go-1-16-3mm3
universal:
universal: generate
@GOOS=darwin GOARCH=amd64 $(GO_ENV) $(GO) build $(GO_FLAGS) -o ${BIN}_amd64 $(DIR_SRC)
@GOOS=darwin GOARCH=arm64 $(GO_ENV) $(GO) build $(GO_FLAGS) -o ${BIN}_arm64 $(DIR_SRC)
@lipo -create -output ${BIN} ${BIN}_amd64 ${BIN}_arm64
@rm -f ${BIN}_amd64 ${BIN}_arm64
generate:
go get github.com/google/wire/cmd/wire@latest
go install github.com/golang/mock/mockgen@v1.6.0
go generate ./...
go mod tidy
@$(GO) get github.com/google/wire/cmd/wire@v0.5.0
@$(GO) get github.com/golang/mock/mockgen@v1.6.0
@$(GO) generate ./...
@$(GO) mod tidy
test:
@$(GO) test ./internal/repo/repo_test

View File

@ -26,13 +26,13 @@ To learn more about the project, visit [answer.dev](https://answer.dev).
docker run -d -p 9080:80 -v answer-data:/data --name answer answerdev/answer:latest
```
For more information, see [INSTALL.md](./INSTALL.md)
For more information, see [Installation](https://answer.dev/docs/installation)
## Contributing
Contributions are always welcome!
See [CONTRIBUTING.md](CONTRIBUTING.md) for ways to get started.
See [CONTRIBUTING](https://answer.dev/docs/development/contributing/) for ways to get started.
## License

View File

@ -26,13 +26,13 @@
docker run -d -p 9080:80 -v answer-data:/data --name answer answerdev/answer:latest
```
其他安装配置细节请参考 [INSTALL_CN.md](./INSTALL_CN.md)
其他安装配置细节请参考 [Installation](https://answer.dev/docs/installation)
## 贡献
我们随时欢迎你的贡献!
参考 [CONTRIBUTING.md](CONTRIBUTING.md) 开始贡献。
参考 [CONTRIBUTING](https://answer.dev/docs/development/contributing/) 开始贡献。
## License

View File

@ -187,7 +187,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
reportAdminService := report_admin.NewReportAdminService(reportRepo, userCommon, commonRepo, answerRepo, questionRepo, commentCommonRepo, reportHandle, configRepo)
controller_adminReportController := controller_admin.NewReportController(reportAdminService)
userAdminRepo := user.NewUserAdminRepo(dataData, authRepo)
userAdminService := user_admin.NewUserAdminService(userAdminRepo, userRoleRelService, authService, userCommon)
userAdminService := user_admin.NewUserAdminService(userAdminRepo, userRoleRelService, authService, userCommon, userActiveActivityRepo)
userAdminController := controller_admin.NewUserAdminController(userAdminService)
reasonRepo := reason.NewReasonRepo(configRepo)
reasonService := reason2.NewReasonService(reasonRepo)

View File

@ -3047,7 +3047,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "UserTop",
"parameters": [
@ -3255,6 +3255,45 @@ const docTemplate = `{
}
}
},
"/answer/api/v1/post/render": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "render post content",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Upload"
],
"summary": "render post content",
"parameters": [
{
"description": "PostRenderReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.PostRenderReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/question": {
"put": {
"security": [
@ -3270,7 +3309,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "update question",
"parameters": [
@ -3307,7 +3346,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "add question",
"parameters": [
@ -3344,7 +3383,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "delete question",
"parameters": [
@ -3383,7 +3422,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "close question msg list",
"responses": {
@ -3403,7 +3442,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "GetQuestion Question",
"description": "get question details",
"consumes": [
"application/json"
],
@ -3411,9 +3450,9 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "GetQuestion Question",
"summary": "get question details",
"parameters": [
{
"type": "string",
@ -3436,7 +3475,7 @@ const docTemplate = `{
},
"/answer/api/v1/question/page": {
"get": {
"description": "SearchQuestionList \u003cbr\u003e \"order\" Enums(newest, active,frequent,score,unanswered)",
"description": "get questions by page",
"consumes": [
"application/json"
],
@ -3444,17 +3483,17 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "SearchQuestionList",
"summary": "get questions by page",
"parameters": [
{
"description": "QuestionSearch",
"description": "QuestionPageReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionSearch"
"$ref": "#/definitions/schema.QuestionPageReq"
}
}
],
@ -3462,7 +3501,34 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"type": "string"
"allOf": [
{
"$ref": "#/definitions/handler.RespBody"
},
{
"type": "object",
"properties": {
"data": {
"allOf": [
{
"$ref": "#/definitions/pager.PageModel"
},
{
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.QuestionPageResp"
}
}
}
}
]
}
}
}
]
}
}
}
@ -3483,7 +3549,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "reopen question",
"parameters": [
@ -3507,40 +3573,6 @@ const docTemplate = `{
}
}
},
"/answer/api/v1/question/search": {
"post": {
"description": "SearchQuestionList",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"api-question"
],
"summary": "SearchQuestionList",
"parameters": [
{
"description": "QuestionSearch",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionSearch"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/answer/api/v1/question/similar": {
"get": {
"security": [
@ -3556,7 +3588,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "add question title like",
"parameters": [
@ -3589,7 +3621,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "Search Similar Question",
"parameters": [
@ -3627,7 +3659,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "Close question",
"parameters": [
@ -5502,7 +5534,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "UserList",
"parameters": [
@ -5886,10 +5918,15 @@ const docTemplate = `{
},
"schema.AnswerAddReq": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"description": "content",
"type": "string"
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"html": {
"description": "html",
@ -5903,10 +5940,15 @@ const docTemplate = `{
},
"schema.AnswerUpdateReq": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"description": "content",
"type": "string"
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"edit_summary": {
"description": "edit_summary",
@ -7088,6 +7130,14 @@ const docTemplate = `{
}
}
},
"schema.PostRenderReq": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
},
"schema.QuestionAdd": {
"type": "object",
"required": [
@ -7124,27 +7174,115 @@ const docTemplate = `{
}
}
},
"schema.QuestionSearch": {
"schema.QuestionPageReq": {
"type": "object",
"properties": {
"order": {
"description": "Search order by",
"type": "string"
"orderCond": {
"type": "string",
"enum": [
"newest",
"active",
"frequent",
"score",
"unanswered"
]
},
"page": {
"description": "Query number of pages",
"type": "integer"
"type": "integer",
"minimum": 1
},
"page_size": {
"description": "Search page size",
"type": "integer"
"pageSize": {
"type": "integer",
"minimum": 1
},
"tag": {
"description": "Tags []string ` + "`" + `json:\"tags\" form:\"tags\"` + "`" + ` // Search tag",
"type": "string"
"type": "string",
"maxLength": 100
},
"username": {
"type": "string",
"maxLength": 100
}
}
},
"schema.QuestionPageResp": {
"type": "object",
"properties": {
"accepted_answer_id": {
"description": "answer information",
"type": "string"
},
"answer_count": {
"type": "integer"
},
"collection_count": {
"type": "integer"
},
"created_at": {
"type": "integer"
},
"description": {
"type": "string"
},
"follow_count": {
"type": "integer"
},
"id": {
"type": "string"
},
"last_answer_id": {
"type": "string"
},
"operated_at": {
"description": "operator information",
"type": "integer"
},
"operation_type": {
"type": "string"
},
"operator": {
"$ref": "#/definitions/schema.QuestionPageRespOperator"
},
"status": {
"type": "integer"
},
"tags": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.TagResp"
}
},
"title": {
"type": "string"
},
"unique_view_count": {
"type": "integer"
},
"url_title": {
"type": "string"
},
"view_count": {
"description": "question statistical information",
"type": "integer"
},
"vote_count": {
"type": "integer"
}
}
},
"schema.QuestionPageRespOperator": {
"type": "object",
"properties": {
"display_name": {
"type": "string"
},
"id": {
"type": "string"
},
"rank": {
"type": "integer"
},
"username": {
"description": "Search username",
"type": "string"
}
}
@ -7362,10 +7500,6 @@ const docTemplate = `{
},
"schema.SiteBrandingReq": {
"type": "object",
"required": [
"logo",
"square_icon"
],
"properties": {
"favicon": {
"type": "string",
@ -7387,10 +7521,6 @@ const docTemplate = `{
},
"schema.SiteBrandingResp": {
"type": "object",
"required": [
"logo",
"square_icon"
],
"properties": {
"favicon": {
"type": "string",
@ -7893,11 +8023,6 @@ const docTemplate = `{
"type": "string",
"maxLength": 4096
},
"bio_html": {
"description": "bio",
"type": "string",
"maxLength": 4096
},
"display_name": {
"description": "display_name",
"type": "string",

View File

@ -3035,7 +3035,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "UserTop",
"parameters": [
@ -3243,6 +3243,45 @@
}
}
},
"/answer/api/v1/post/render": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "render post content",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Upload"
],
"summary": "render post content",
"parameters": [
{
"description": "PostRenderReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.PostRenderReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/question": {
"put": {
"security": [
@ -3258,7 +3297,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "update question",
"parameters": [
@ -3295,7 +3334,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "add question",
"parameters": [
@ -3332,7 +3371,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "delete question",
"parameters": [
@ -3371,7 +3410,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "close question msg list",
"responses": {
@ -3391,7 +3430,7 @@
"ApiKeyAuth": []
}
],
"description": "GetQuestion Question",
"description": "get question details",
"consumes": [
"application/json"
],
@ -3399,9 +3438,9 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "GetQuestion Question",
"summary": "get question details",
"parameters": [
{
"type": "string",
@ -3424,7 +3463,7 @@
},
"/answer/api/v1/question/page": {
"get": {
"description": "SearchQuestionList \u003cbr\u003e \"order\" Enums(newest, active,frequent,score,unanswered)",
"description": "get questions by page",
"consumes": [
"application/json"
],
@ -3432,17 +3471,17 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "SearchQuestionList",
"summary": "get questions by page",
"parameters": [
{
"description": "QuestionSearch",
"description": "QuestionPageReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionSearch"
"$ref": "#/definitions/schema.QuestionPageReq"
}
}
],
@ -3450,7 +3489,34 @@
"200": {
"description": "OK",
"schema": {
"type": "string"
"allOf": [
{
"$ref": "#/definitions/handler.RespBody"
},
{
"type": "object",
"properties": {
"data": {
"allOf": [
{
"$ref": "#/definitions/pager.PageModel"
},
{
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.QuestionPageResp"
}
}
}
}
]
}
}
}
]
}
}
}
@ -3471,7 +3537,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "reopen question",
"parameters": [
@ -3495,40 +3561,6 @@
}
}
},
"/answer/api/v1/question/search": {
"post": {
"description": "SearchQuestionList",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"api-question"
],
"summary": "SearchQuestionList",
"parameters": [
{
"description": "QuestionSearch",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionSearch"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/answer/api/v1/question/similar": {
"get": {
"security": [
@ -3544,7 +3576,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "add question title like",
"parameters": [
@ -3577,7 +3609,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "Search Similar Question",
"parameters": [
@ -3615,7 +3647,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "Close question",
"parameters": [
@ -5490,7 +5522,7 @@
"application/json"
],
"tags": [
"api-question"
"Question"
],
"summary": "UserList",
"parameters": [
@ -5874,10 +5906,15 @@
},
"schema.AnswerAddReq": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"description": "content",
"type": "string"
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"html": {
"description": "html",
@ -5891,10 +5928,15 @@
},
"schema.AnswerUpdateReq": {
"type": "object",
"required": [
"content"
],
"properties": {
"content": {
"description": "content",
"type": "string"
"type": "string",
"maxLength": 65535,
"minLength": 6
},
"edit_summary": {
"description": "edit_summary",
@ -7076,6 +7118,14 @@
}
}
},
"schema.PostRenderReq": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
},
"schema.QuestionAdd": {
"type": "object",
"required": [
@ -7112,27 +7162,115 @@
}
}
},
"schema.QuestionSearch": {
"schema.QuestionPageReq": {
"type": "object",
"properties": {
"order": {
"description": "Search order by",
"type": "string"
"orderCond": {
"type": "string",
"enum": [
"newest",
"active",
"frequent",
"score",
"unanswered"
]
},
"page": {
"description": "Query number of pages",
"type": "integer"
"type": "integer",
"minimum": 1
},
"page_size": {
"description": "Search page size",
"type": "integer"
"pageSize": {
"type": "integer",
"minimum": 1
},
"tag": {
"description": "Tags []string `json:\"tags\" form:\"tags\"` // Search tag",
"type": "string"
"type": "string",
"maxLength": 100
},
"username": {
"type": "string",
"maxLength": 100
}
}
},
"schema.QuestionPageResp": {
"type": "object",
"properties": {
"accepted_answer_id": {
"description": "answer information",
"type": "string"
},
"answer_count": {
"type": "integer"
},
"collection_count": {
"type": "integer"
},
"created_at": {
"type": "integer"
},
"description": {
"type": "string"
},
"follow_count": {
"type": "integer"
},
"id": {
"type": "string"
},
"last_answer_id": {
"type": "string"
},
"operated_at": {
"description": "operator information",
"type": "integer"
},
"operation_type": {
"type": "string"
},
"operator": {
"$ref": "#/definitions/schema.QuestionPageRespOperator"
},
"status": {
"type": "integer"
},
"tags": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.TagResp"
}
},
"title": {
"type": "string"
},
"unique_view_count": {
"type": "integer"
},
"url_title": {
"type": "string"
},
"view_count": {
"description": "question statistical information",
"type": "integer"
},
"vote_count": {
"type": "integer"
}
}
},
"schema.QuestionPageRespOperator": {
"type": "object",
"properties": {
"display_name": {
"type": "string"
},
"id": {
"type": "string"
},
"rank": {
"type": "integer"
},
"username": {
"description": "Search username",
"type": "string"
}
}
@ -7350,10 +7488,6 @@
},
"schema.SiteBrandingReq": {
"type": "object",
"required": [
"logo",
"square_icon"
],
"properties": {
"favicon": {
"type": "string",
@ -7375,10 +7509,6 @@
},
"schema.SiteBrandingResp": {
"type": "object",
"required": [
"logo",
"square_icon"
],
"properties": {
"favicon": {
"type": "string",
@ -7881,11 +8011,6 @@
"type": "string",
"maxLength": 4096
},
"bio_html": {
"description": "bio",
"type": "string",
"maxLength": 4096
},
"display_name": {
"description": "display_name",
"type": "string",

View File

@ -218,6 +218,8 @@ definitions:
properties:
content:
description: content
maxLength: 65535
minLength: 6
type: string
html:
description: html
@ -225,11 +227,15 @@ definitions:
question_id:
description: question_id
type: string
required:
- content
type: object
schema.AnswerUpdateReq:
properties:
content:
description: content
maxLength: 65535
minLength: 6
type: string
edit_summary:
description: edit_summary
@ -246,6 +252,8 @@ definitions:
title:
description: title
type: string
required:
- content
type: object
schema.AvatarInfo:
properties:
@ -1076,6 +1084,11 @@ definitions:
type:
type: string
type: object
schema.PostRenderReq:
properties:
content:
type: string
type: object
schema.QuestionAdd:
properties:
content:
@ -1104,23 +1117,82 @@ definitions:
- tags
- title
type: object
schema.QuestionSearch:
schema.QuestionPageReq:
properties:
order:
description: Search order by
orderCond:
enum:
- newest
- active
- frequent
- score
- unanswered
type: string
page:
description: Query number of pages
minimum: 1
type: integer
page_size:
description: Search page size
pageSize:
minimum: 1
type: integer
tag:
description: Tags []string `json:"tags" form:"tags"` // Search
tag
maxLength: 100
type: string
username:
description: Search username
maxLength: 100
type: string
type: object
schema.QuestionPageResp:
properties:
accepted_answer_id:
description: answer information
type: string
answer_count:
type: integer
collection_count:
type: integer
created_at:
type: integer
description:
type: string
follow_count:
type: integer
id:
type: string
last_answer_id:
type: string
operated_at:
description: operator information
type: integer
operation_type:
type: string
operator:
$ref: '#/definitions/schema.QuestionPageRespOperator'
status:
type: integer
tags:
items:
$ref: '#/definitions/schema.TagResp'
type: array
title:
type: string
unique_view_count:
type: integer
url_title:
type: string
view_count:
description: question statistical information
type: integer
vote_count:
type: integer
type: object
schema.QuestionPageRespOperator:
properties:
display_name:
type: string
id:
type: string
rank:
type: integer
username:
type: string
type: object
schema.QuestionUpdate:
@ -1284,9 +1356,6 @@ definitions:
square_icon:
maxLength: 512
type: string
required:
- logo
- square_icon
type: object
schema.SiteBrandingResp:
properties:
@ -1302,9 +1371,6 @@ definitions:
square_icon:
maxLength: 512
type: string
required:
- logo
- square_icon
type: object
schema.SiteCustomCssHTMLReq:
properties:
@ -1638,10 +1704,6 @@ definitions:
description: bio
maxLength: 4096
type: string
bio_html:
description: bio
maxLength: 4096
type: string
display_name:
description: display_name
maxLength: 30
@ -3857,7 +3919,7 @@ paths:
- ApiKeyAuth: []
summary: UserTop
tags:
- api-question
- Question
/answer/api/v1/personal/rank/page:
get:
description: user personal rank list
@ -3962,6 +4024,30 @@ paths:
summary: user's votes
tags:
- Activity
/answer/api/v1/post/render:
post:
consumes:
- application/json
description: render post content
parameters:
- description: PostRenderReq
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.PostRenderReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: render post content
tags:
- Upload
/answer/api/v1/question:
delete:
consumes:
@ -3985,7 +4071,7 @@ paths:
- ApiKeyAuth: []
summary: delete question
tags:
- api-question
- Question
post:
consumes:
- application/json
@ -4008,7 +4094,7 @@ paths:
- ApiKeyAuth: []
summary: add question
tags:
- api-question
- Question
put:
consumes:
- application/json
@ -4031,7 +4117,7 @@ paths:
- ApiKeyAuth: []
summary: update question
tags:
- api-question
- Question
/answer/api/v1/question/closemsglist:
get:
consumes:
@ -4048,12 +4134,12 @@ paths:
- ApiKeyAuth: []
summary: close question msg list
tags:
- api-question
- Question
/answer/api/v1/question/info:
get:
consumes:
- application/json
description: GetQuestion Question
description: get question details
parameters:
- default: "1"
description: Question TagID
@ -4070,31 +4156,43 @@ paths:
type: string
security:
- ApiKeyAuth: []
summary: GetQuestion Question
summary: get question details
tags:
- api-question
- Question
/answer/api/v1/question/page:
get:
consumes:
- application/json
description: SearchQuestionList <br> "order" Enums(newest, active,frequent,score,unanswered)
description: get questions by page
parameters:
- description: QuestionSearch
- description: QuestionPageReq
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.QuestionSearch'
$ref: '#/definitions/schema.QuestionPageReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
summary: SearchQuestionList
allOf:
- $ref: '#/definitions/handler.RespBody'
- properties:
data:
allOf:
- $ref: '#/definitions/pager.PageModel'
- properties:
list:
items:
$ref: '#/definitions/schema.QuestionPageResp'
type: array
type: object
type: object
summary: get questions by page
tags:
- api-question
- Question
/answer/api/v1/question/reopen:
put:
consumes:
@ -4118,29 +4216,7 @@ paths:
- ApiKeyAuth: []
summary: reopen question
tags:
- api-question
/answer/api/v1/question/search:
post:
consumes:
- application/json
description: SearchQuestionList
parameters:
- description: QuestionSearch
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.QuestionSearch'
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
summary: SearchQuestionList
tags:
- api-question
- Question
/answer/api/v1/question/similar:
get:
consumes:
@ -4164,7 +4240,7 @@ paths:
- ApiKeyAuth: []
summary: add question title like
tags:
- api-question
- Question
/answer/api/v1/question/similar/tag:
get:
consumes:
@ -4186,7 +4262,7 @@ paths:
type: string
summary: Search Similar Question
tags:
- api-question
- Question
/answer/api/v1/question/status:
put:
consumes:
@ -4210,7 +4286,7 @@ paths:
- ApiKeyAuth: []
summary: Close question
tags:
- api-question
- Question
/answer/api/v1/question/tags:
get:
description: get tag list
@ -5362,7 +5438,7 @@ paths:
- ApiKeyAuth: []
summary: UserList
tags:
- api-question
- Question
/robots.txt:
get:
description: get site robots information

19
go.mod
View File

@ -5,6 +5,7 @@ go 1.18
require (
github.com/Chain-Zhang/pinyin v0.1.3
github.com/anargu/gin-brotli v0.0.0-20220116052358-12bf532d5267
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
github.com/bwmarrin/snowflake v0.3.0
github.com/davecgh/go-spew v1.1.1
github.com/disintegration/imaging v1.6.2
@ -22,7 +23,7 @@ require (
github.com/jinzhu/copier v0.3.5
github.com/jinzhu/now v1.1.5
github.com/lib/pq v1.10.7
github.com/mattn/go-sqlite3 v1.14.16
github.com/microcosm-cc/bluemonday v1.0.21
github.com/mojocn/base64Captcha v1.3.5
github.com/ory/dockertest/v3 v3.9.1
github.com/otiai10/copy v1.7.0
@ -43,6 +44,7 @@ require (
golang.org/x/net v0.1.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.14.2
xorm.io/builder v0.3.12
xorm.io/core v0.7.3
xorm.io/xorm v1.3.2
@ -55,6 +57,7 @@ require (
github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/containerd/continuity v0.3.0 // indirect
github.com/docker/cli v20.10.14+incompatible // indirect
@ -72,18 +75,21 @@ require (
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible // indirect
github.com/lestrrat-go/strftime v1.0.6 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@ -96,6 +102,7 @@ require (
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/afero v1.9.2 // indirect
github.com/spf13/cast v1.5.0 // indirect
@ -112,6 +119,7 @@ require (
go.uber.org/multierr v1.8.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/image v0.1.0 // indirect
golang.org/x/mod v0.6.0 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/tools v0.2.0 // indirect
@ -119,5 +127,14 @@ require (
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
lukechampine.com/uint128 v1.1.1 // indirect
modernc.org/cc/v3 v3.35.18 // indirect
modernc.org/ccgo/v3 v3.12.82 // indirect
modernc.org/libc v1.11.87 // indirect
modernc.org/mathutil v1.4.1 // indirect
modernc.org/memory v1.0.5 // indirect
modernc.org/opt v0.1.1 // indirect
modernc.org/strutil v1.1.1 // indirect
modernc.org/token v1.0.0 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)

14
go.sum
View File

@ -79,9 +79,13 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@ -140,6 +144,7 @@ github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5Xh
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
@ -301,6 +306,8 @@ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5m
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
@ -476,6 +483,8 @@ github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
@ -792,6 +801,7 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -1222,7 +1232,9 @@ modernc.org/ccgo/v3 v3.12.73/go.mod h1:hngkB+nUUqzOf3iqsM48Gf1FZhY599qzVg1iX+BT3
modernc.org/ccgo/v3 v3.12.81/go.mod h1:p2A1duHoBBg1mFtYvnhAnQyI6vL0uw5PGYLSIgF6rYY=
modernc.org/ccgo/v3 v3.12.82 h1:wudcnJyjLj1aQQCXF3IM9Gz2X6UNjw+afIghzdtn0v8=
modernc.org/ccgo/v3 v3.12.82/go.mod h1:ApbflUfa5BKadjHynCficldU1ghjen84tuM5jRynB7w=
modernc.org/ccorpus v1.11.1 h1:K0qPfpVG1MJh5BYazccnmhywH4zHuOgJXgbjzyp6dWA=
modernc.org/ccorpus v1.11.1/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q=
@ -1274,9 +1286,11 @@ modernc.org/sqlite v1.14.2 h1:ohsW2+e+Qe2To1W6GNezzKGwjXwSax6R+CrhRxVaFbE=
modernc.org/sqlite v1.14.2/go.mod h1:yqfn85u8wVOE6ub5UT8VI9JjhrwBUUCNyTACN0h6Sx8=
modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/tcl v1.8.13 h1:V0sTNBw0Re86PvXZxuCub3oO9WrSTqALgrwNZNvLFGw=
modernc.org/tcl v1.8.13/go.mod h1:V+q/Ef0IJaNUSECieLU4o+8IScapxnMyFV6i/7uQlAY=
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.2.19 h1:BGyRFWhDVn5LFS5OcX4Yd/MlpRTOc7hOPTdcIpCiUao=
modernc.org/z v1.2.19/go.mod h1:+ZpP0pc4zz97eukOzW3xagV/lS82IpPN9NGG5pNF9vY=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=

View File

@ -2,65 +2,65 @@
backend:
base:
success:
other: "Success."
other: "Erfolgreich."
unknown:
other: "Unknown error."
other: "Unbekannter Fehler."
request_format_error:
other: "Request format is not valid."
unauthorized_error:
other: "Unauthorized."
other: "Nicht autorisiert."
database_error:
other: "Data server error."
role:
name:
user:
other: "User"
other: "Nutzer"
admin:
other: "Admin"
moderator:
other: "Moderator"
description:
user:
other: "Default with no special access."
other: "Standard ohne speziellen Zugriff."
admin:
other: "Have the full power to access the site."
moderator:
other: "Has access to all posts except admin settings."
other: "Hat Zugriff auf alle Beiträge außer Admin-Einstellungen."
email:
other: "Email"
other: "E-Mail"
password:
other: "Password"
other: "Passwort"
email_or_password_wrong_error:
other: "Email and password do not match."
other: "E-Mail und Password stimmen nicht überein."
error:
admin:
email_or_password_wrong:
other: Email and password do not match.
other: E-Mail und Password stimmen nicht überein.
answer:
not_found:
other: "Answer do not found."
cannot_deleted:
other: "No permission to delete."
other: "Keine Berechtigung zum Löschen."
cannot_update:
other: "No permission to update."
other: "Keine Berechtigung zum Aktualisieren."
comment:
edit_without_permission:
other: "Comment are not allowed to edit."
other: "Kommentar kann nicht bearbeitet werden."
not_found:
other: "Comment not found."
other: "Kommentar wurde nicht gefunden."
email:
duplicate:
other: "Email already exists."
other: "E-Mail existiert bereits."
need_to_be_verified:
other: "Email should be verified."
other: "E-Mail muss überprüft werden."
verify_url_expired:
other: "Email verified URL has expired, please resend the email."
lang:
not_found:
other: "Language file not found."
other: "Sprachdatei nicht gefunden."
object:
captcha_verification_failed:
other: "Captcha wrong."
other: "Captcha ist falsch."
disallow_follow:
other: "You are not allowed to follow."
disallow_vote:
@ -70,22 +70,22 @@ backend:
not_found:
other: "Object not found."
verification_failed:
other: "Verification failed."
other: "Verifizierung fehlgeschlagen."
email_or_password_incorrect:
other: "Email and password do not match."
other: "E-Mail und Password stimmen nicht überein."
old_password_verification_failed:
other: "The old password verification failed"
new_password_same_as_previous_setting:
other: "The new password is the same as the previous one."
other: "Das neue Passwort ist das gleiche wie das vorherige Passwort."
question:
not_found:
other: "Question not found."
other: "Frage nicht gefunden."
cannot_deleted:
other: "No permission to delete."
other: "Keine Berechtigung zum Löschen."
cannot_close:
other: "No permission to close."
cannot_update:
other: "No permission to update."
other: "Keine Berechtigung zum Aktualisieren."
rank:
fail_to_meet_the_condition:
other: "Rank fail to meet the condition."
@ -96,7 +96,7 @@ backend:
other: "Report not found."
tag:
not_found:
other: "Tag not found."
other: "Schlagwort nicht gefunden."
recommend_tag_not_found:
other: "Recommend Tag is not exist."
recommend_tag_enter:
@ -107,6 +107,9 @@ backend:
other: "No permission to update."
cannot_set_synonym_as_itself:
other: "You cannot set the synonym of the current tag as itself."
smtp:
config_from_name_cannot_be_email:
other: "The From Name cannot be a email address."
theme:
not_found:
other: "Theme not found."
@ -129,21 +132,21 @@ backend:
other: "Username is already in use."
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
cannot_update_your_role:
other: "You cannot modify your role."
not_allowed_registration:
other: "Currently the site is not open for registration"
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:
spam:
name:
@ -181,7 +184,7 @@ backend:
name:
other: "spam"
desc:
other: "This question has been asked before and already has an answer."
other: "Diese Frage ist bereits gestellt worden und hat bereits eine Antwort."
guideline:
name:
other: "a community-specific reason"
@ -197,6 +200,13 @@ backend:
other: "something else"
desc:
other: "This post requires another reason not listed above."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -230,24 +240,24 @@ ui:
desc: >-
<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
prev: Zurück
next: Weiter
page_title:
question: Question
questions: Questions
tag: Tag
tags: Tags
question: Frage
questions: Fragen
tag: Schlagwort
tags: Schlagwörter
tag_wiki: tag wiki
edit_tag: Edit Tag
ask_a_question: Add Question
edit_question: Edit Question
edit_answer: Edit Answer
search: Search
search: Suche
posts_containing: Posts containing
settings: Settings
settings: Einstellungen
notifications: Notifications
login: Log In
sign_up: Sign Up
login: Anmelden
sign_up: Registrieren
account_recovery: Account Recovery
account_activation: Account Activation
confirm_email: Confirm Email
@ -367,7 +377,7 @@ ui:
unordered_list:
text: Bulleted List
table:
text: Table
text: Tabelle
heading: Heading
cell: Cell
close_modal:
@ -586,13 +596,13 @@ ui:
empty: Name cannot be empty.
range: Name up to 30 characters.
email:
label: Email
label: E-Mail
msg:
empty: Email cannot be empty.
empty: E-Mail-Feld darf nicht leer sein.
password:
label: Password
label: Passwort
msg:
empty: Password cannot be empty.
empty: Passwort-Feld darf nicht leer sein.
different: The passwords entered on both sides are inconsistent
account_forgot:
page_title: Forgot Your Password
@ -600,7 +610,7 @@ ui:
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
label: E-Mail
msg:
empty: Email cannot be empty.
change_email:
@ -674,24 +684,24 @@ ui:
radio: "Answers to your questions, comments, and more"
account:
heading: Account
change_email_btn: Change email
change_pass_btn: Change password
change_email_btn: E-Mail-Adresse ändern
change_pass_btn: Passwort ändern
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
label: E-Mail
msg: E-Mail-Feld darf nicht leer sein.
password_title: Passwort
current_pass:
label: Current Password
label: Aktuelles Passwort
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
label: Neues Passwort
pass_confirm:
label: Confirm New Password
label: Neues Passwort bestätigen
interface:
heading: Interface
lang:
@ -747,10 +757,10 @@ ui:
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
confirm: Bestätigen
cancel: Abbrechen
save: Speichern
delete: Löschen
login: Log in
signup: Sign up
logout: Log out
@ -801,6 +811,11 @@ ui:
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?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -871,7 +886,7 @@ ui:
placeholder: root
msg: Username cannot be empty.
db_password:
label: Password
label: Passwort
placeholder: root
msg: Password cannot be empty.
db_host:
@ -991,13 +1006,14 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to
latest: Latest
check_failed: Check failed
"yes": "Yes"
"no": "No"
"yes": "Ja"
"no": "Nein"
not_allowed: Not allowed
allowed: Allowed
enabled: Enabled
@ -1045,7 +1061,7 @@ ui:
users:
title: Users
name: Name
email: Email
email: E-Mail
reputation: Reputation
created_at: Created Time
delete_at: Deleted Time
@ -1184,7 +1200,7 @@ ui:
ssl: SSL
none: None
smtp_port:
label: SMTP Port
label: SMTP-Port
msg: SMTP port must be number 1 ~ 65535.
text: The port to your mail server.
smtp_username:

View File

@ -52,6 +52,8 @@ backend:
other: "Comment are not allowed to edit."
not_found:
other: "Comment not found."
cannot_edit_after_deadline:
other: "The comment time has been too long to modify."
email:
duplicate:
other: "Email already exists."
@ -135,21 +137,21 @@ backend:
other: "Username is already in use."
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
cannot_update_your_role:
other: "You cannot modify your role."
not_allowed_registration:
other: "Currently the site is not open for registration"
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:
spam:
name:
@ -204,7 +206,13 @@ backend:
other: "something else"
desc:
other: "This post requires another reason not listed above."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -601,7 +609,8 @@ ui:
msg:
empty: Cannot be empty.
login:
page_title: Welcome to Answer
page_title: Welcome to {{site_name}}
login_to_continue: Log in to continue
info_sign: Don't have an account? <1>Sign up</1>
info_login: Already have an account? <1>Log in</1>
agreements: By registering, you agree to the <1>privacy policy</1> and <3>terms of service</3>.
@ -841,6 +850,11 @@ ui:
confirm_new_email_invalid: >-
Sorry, this confirmation link is no longer valid. Perhaps your email was
already changed?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -1037,6 +1051,7 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to
@ -1254,14 +1269,14 @@ ui:
branding:
page_title: Branding
logo:
label: Logo
label: Logo (optional)
msg: Logo cannot be empty.
text: The logo image at the top left of your site. Use a wide rectangular image with a height of 56 and an aspect ratio greater than 3:1. If left blank, the site title text will be shown.
mobile_logo:
label: Mobile Logo (optional)
text: The logo used on mobile version of your site. Use a wide rectangular image with a height of 56. If left blank, the image from the “logo” setting will be used.
square_icon:
label: Square Icon
label: Square Icon (optional)
msg: Square icon cannot be empty.
text: Image used as the base for metadata icons. Should ideally be larger than 512x512.
favicon:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -14,18 +14,18 @@ backend:
role:
name:
user:
other: "User"
other: "Utente"
admin:
other: "Admin"
other: "Amministratore"
moderator:
other: "Moderator"
other: "Moderatore"
description:
user:
other: "Default with no special access."
other: "Predefinito senza alcun accesso speciale."
admin:
other: "Have the full power to access the site."
other: "Avere il pieno potere di accedere al sito."
moderator:
other: "Has access to all posts except admin settings."
other: "Ha accesso a tutti i post tranne le impostazioni di amministratore."
email:
other: "email"
password:
@ -40,9 +40,9 @@ backend:
not_found:
other: "Risposta non trovata"
cannot_deleted:
other: "No permission to delete."
other: "Permesso per cancellare mancante."
cannot_update:
other: "No permission to update."
other: "Nessun permesso per l'aggiornamento."
comment:
edit_without_permission:
other: "Non si hanno di privilegi sufficienti per modificare il commento"
@ -81,11 +81,11 @@ backend:
not_found:
other: "domanda non trovata"
cannot_deleted:
other: "No permission to delete."
other: "Permesso per cancellare mancante."
cannot_close:
other: "No permission to close."
other: "Nessun permesso per chiudere."
cannot_update:
other: "No permission to update."
other: "Nessun permesso per l'aggiornamento."
rank:
fail_to_meet_the_condition:
other: "Condizioni non valide per il grado"
@ -98,23 +98,26 @@ backend:
not_found:
other: "Etichetta non trovata"
recommend_tag_not_found:
other: "Recommend Tag is not exist."
other: "Il Tag consigliato non esiste."
recommend_tag_enter:
other: "Please enter at least one required tag."
other: "Inserisci almeno un tag."
not_contain_synonym_tags:
other: "Should not contain synonym tags."
other: "Non deve contenere tag sinonimi."
cannot_update:
other: "No permission to update."
other: "Nessun permesso per l'aggiornamento."
cannot_set_synonym_as_itself:
other: "You cannot set the synonym of the current tag as itself."
other: "Non puoi impostare il sinonimo del tag corrente come se stesso."
smtp:
config_from_name_cannot_be_email:
other: "The From Name cannot be a email address."
theme:
not_found:
other: "tema non trovato"
revision:
review_underway:
other: "Can't edit currently, there is a version in the review queue."
other: "Non è possibile modificare al momento, c'è una versione nella coda di revisione."
no_permission:
other: "No permission to Revision."
other: "Nessun permesso per la revisione."
user:
email_or_password_wrong:
other:
@ -128,75 +131,82 @@ backend:
username_duplicate:
other: "utente già in uso"
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
other: "Inserimento dell'Avatar non riuscito."
cannot_update_your_role:
other: "You cannot modify your role."
other: "Non puoi modificare il tuo ruolo."
not_allowed_registration:
other: "Currently the site is not open for registration"
other: "Al momento il sito non è aperto per la registrazione"
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:
spam:
name:
other: "spam"
other: "posta indesiderata"
desc:
other: "This post is an advertisement, or vandalism. It is not useful or relevant to the current topic."
other: "Questo articolo è una pubblicità o vandalismo. Non è utile o rilevante all'argomento corrente."
rude:
name:
other: "scortese o violento"
desc:
other: "A reasonable person would find this content inappropriate for respectful discourse."
other: "Una persona ragionevole trova questo contenuto inappropriato a un discorso rispettoso."
duplicate:
name:
other: "duplicato"
desc:
other: "This question has been asked before and already has an answer."
other: "Questa domanda è già stata posta e ha già una risposta."
not_answer:
name:
other: "non è una risposta"
desc:
other: "This was posted as an answer, but it does not attempt to answer the question. It should possibly be an edit, a comment, another question, or deleted altogether."
other: "Questo è stato pubblicato come una risposta, ma non tenta di rispondere alla domanda. Dovrebbe forse essere una modifica, un commento, un'altra domanda, o cancellata del tutto."
not_need:
name:
other: "non più necessario"
desc:
other: "This comment is outdated, conversational or not relevant to this post."
other: "Questo commento è obsoleto, conversazionale o non pertinente per questo post."
other:
name:
other: "altro"
desc:
other: "This post requires staff attention for another reason not listed above."
other: "Questo articolo richiede una supervisione dello staff per altre ragioni non listate sopra."
question:
close:
duplicate:
name:
other: "spam"
other: "posta indesiderata"
desc:
other: "This question has been asked before and already has an answer."
other: "Questa domanda è già stata posta e ha già una risposta."
guideline:
name:
other: "motivo legato alla community"
desc:
other: "This question doesn't meet a community guideline."
other: "Questa domanda non soddisfa le linee guida della comunità."
multiple:
name:
other: "richiede maggiori dettagli o chiarezza"
desc:
other: "This question currently includes multiple questions in one. It should focus on one problem only."
other: "Questa domanda attualmente include più domande in uno. Dovrebbe concentrarsi su un solo problema."
other:
name:
other: "altro"
desc:
other: "This post requires another reason not listed above."
other: "Questo articolo richiede un'altro motivo non listato sopra."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -206,7 +216,7 @@ backend:
update_answer:
other: "risposta aggiornata"
accept_answer:
other: "risposta accepted"
other: "risposta accettata"
comment_question:
other: "domanda commentata"
comment_answer:
@ -226,21 +236,21 @@ backend:
#The following fields are used for interface presentation(Front-end)
ui:
how_to_format:
title: How to Format
title: Come formattare
desc: >-
<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>
<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. om)</code></pre></li><li><p class="mb-2">mette i rendimenti tra i paragrafi</p></li><li><p class="mb-2"><em>_italic_</em> o **<strong>grassetto</strong>**</p></li><li><p class="mb-2">trattino di codice per 4 spazi</p></li><li><p class="mb-2">preventivo inserendo <code>&gt;</code> all'inizio della riga</p></li><li><p class="mb-2">backtick escapes <code>`like _this_`</code></p></li><li><p class="mb-2">crea recinzioni di codice con backticks <code>`</code></p><pre class="mb-0">````<code><br/>codice qui<br/>`````</code></pre></li></ul>
pagination:
prev: Prev
next: Next
prev: Prec
next: Successivo
page_title:
question: Question
questions: Questions
question: Domanda
questions: Domande
tag: Tag
tags: Tags
tag_wiki: tag wiki
edit_tag: Edit Tag
ask_a_question: Add Question
edit_question: Edit Question
edit_tag: Modifica Tag
ask_a_question: Aggiungi una domanda
edit_question: Modifica Domanda
edit_answer: Edit Answer
search: Search
posts_containing: Posts containing
@ -801,6 +811,11 @@ ui:
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?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -991,6 +1006,7 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to

View File

@ -107,6 +107,9 @@ backend:
other: "No permission to update."
cannot_set_synonym_as_itself:
other: "You cannot set the synonym of the current tag as itself."
smtp:
config_from_name_cannot_be_email:
other: "The From Name cannot be a email address."
theme:
not_found:
other: "Theme not found."
@ -129,21 +132,21 @@ backend:
other: "Username is already in use."
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
cannot_update_your_role:
other: "You cannot modify your role."
not_allowed_registration:
other: "Currently the site is not open for registration"
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:
spam:
name:
@ -197,6 +200,13 @@ backend:
other: "something else"
desc:
other: "This post requires another reason not listed above."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -801,6 +811,11 @@ ui:
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?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -991,6 +1006,7 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to

View File

@ -107,6 +107,9 @@ backend:
other: "No permission to update."
cannot_set_synonym_as_itself:
other: "You cannot set the synonym of the current tag as itself."
smtp:
config_from_name_cannot_be_email:
other: "The From Name cannot be a email address."
theme:
not_found:
other: "Theme not found."
@ -129,21 +132,21 @@ backend:
other: "Username is already in use."
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
cannot_update_your_role:
other: "You cannot modify your role."
not_allowed_registration:
other: "Currently the site is not open for registration"
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:
spam:
name:
@ -197,6 +200,13 @@ backend:
other: "something else"
desc:
other: "This post requires another reason not listed above."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -801,6 +811,11 @@ ui:
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?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -991,6 +1006,7 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to

View File

@ -2,15 +2,15 @@
backend:
base:
success:
other: "Success."
other: "Sucesso."
unknown:
other: "Unknown error."
other: "Erro desconhecido."
request_format_error:
other: "Request format is not valid."
other: "Formato de solicitação não é válido."
unauthorized_error:
other: "Unauthorized."
other: "Não autorizado."
database_error:
other: "Data server error."
other: "Erro no servidor de dados."
role:
name:
user:
@ -107,6 +107,9 @@ backend:
other: "No permission to update."
cannot_set_synonym_as_itself:
other: "You cannot set the synonym of the current tag as itself."
smtp:
config_from_name_cannot_be_email:
other: "The From Name cannot be a email address."
theme:
not_found:
other: "Theme not found."
@ -129,21 +132,21 @@ backend:
other: "Username is already in use."
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
cannot_update_your_role:
other: "You cannot modify your role."
not_allowed_registration:
other: "Currently the site is not open for registration"
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:
spam:
name:
@ -197,6 +200,13 @@ backend:
other: "something else"
desc:
other: "This post requires another reason not listed above."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -801,6 +811,11 @@ ui:
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?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -991,6 +1006,7 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to

View File

@ -107,6 +107,9 @@ backend:
other: "No permission to update."
cannot_set_synonym_as_itself:
other: "You cannot set the synonym of the current tag as itself."
smtp:
config_from_name_cannot_be_email:
other: "The From Name cannot be a email address."
theme:
not_found:
other: "Theme not found."
@ -129,21 +132,21 @@ backend:
other: "Username is already in use."
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
cannot_update_your_role:
other: "You cannot modify your role."
not_allowed_registration:
other: "Currently the site is not open for registration"
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:
spam:
name:
@ -197,6 +200,13 @@ backend:
other: "something else"
desc:
other: "This post requires another reason not listed above."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -801,6 +811,11 @@ ui:
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?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -991,6 +1006,7 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to

View File

@ -107,6 +107,9 @@ backend:
other: "No permission to update."
cannot_set_synonym_as_itself:
other: "You cannot set the synonym of the current tag as itself."
smtp:
config_from_name_cannot_be_email:
other: "The From Name cannot be a email address."
theme:
not_found:
other: "Theme not found."
@ -129,21 +132,21 @@ backend:
other: "Username is already in use."
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
cannot_update_your_role:
other: "You cannot modify your role."
not_allowed_registration:
other: "Currently the site is not open for registration"
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:
spam:
name:
@ -197,6 +200,13 @@ backend:
other: "something else"
desc:
other: "This post requires another reason not listed above."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -801,6 +811,11 @@ ui:
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?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -991,6 +1006,7 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to

View File

@ -2,15 +2,15 @@
backend:
base:
success:
other: "成功"
other: "成功"
unknown:
other: "未知错误"
other: "未知错误"
request_format_error:
other: "请求格式错误"
other: "请求格式错误"
unauthorized_error:
other: "未登录"
other: "未授权。"
database_error:
other: "数据服务异常"
other: "数据服务器错误。"
role:
name:
user:
@ -23,38 +23,38 @@ backend:
user:
other: "默认没有特殊访问权限。"
admin:
other: "拥有进入网站的全部权限。"
other: "拥有管理网站的全部权限。"
moderator:
other: "有权访问所有的帖子,无法进入管理员设置页面。"
other: "拥有访问除管理员设置以外的所有权限。"
email:
other: "邮箱"
password:
other: "密码"
email_or_password_wrong_error:
other: "邮箱或密码错误"
other: "邮箱和密码不匹配。"
error:
admin:
email_or_password_wrong:
other: 邮箱或密码错误
other: 邮箱和密码不匹配。
answer:
not_found:
other: "答案未找到"
other: "没有找到答案。"
cannot_deleted:
other: "无删除权限"
other: "没有删除权限。"
cannot_update:
other: "无修改权限"
other: "没有更新权限。"
comment:
edit_without_permission:
other: "不允许编辑评论"
other: "不允许编辑评论"
not_found:
other: "评论未找到"
other: "评论未找到"
email:
duplicate:
other: "邮箱已经存在"
other: "邮箱已经存在"
need_to_be_verified:
other: "邮箱需要验证"
other: "邮箱需要验证"
verify_url_expired:
other: "邮箱验证的网址已过期,请重新发送邮件"
other: "邮箱验证的网址已过期,请重新发送邮件"
lang:
not_found:
other: "语言未找到"
@ -106,7 +106,10 @@ backend:
cannot_update:
other: "没有更新标签权限。"
cannot_set_synonym_as_itself:
other: "你无法将当前标签的同义词设置为当前标签自己"
other: "您不能将当前标签的同义词设置为本身。"
smtp:
config_from_name_cannot_be_email:
other: "发件人名称不能是电子邮件地址。"
theme:
not_found:
other: "主题未找到"
@ -129,75 +132,81 @@ backend:
other: "用户名已被使用"
set_avatar:
other: "头像设置错误"
config:
read_config_failed:
other: "读取配置失败"
database:
connection_failed:
other: "数据连接异常!"
create_table_failed:
other: "创建表失败"
install:
create_config_failed:
other: "无法创建配置文件"
cannot_update_your_role:
other: "你无法修改自己的角色"
other: "您不能修改自己的角色。"
not_allowed_registration:
other: "目前该网站尚未开放注册"
other: "目前该站点未开放注册"
config:
read_config_failed:
other: "读取配置失败"
database:
connection_failed:
other: "数据库连接失败"
create_table_failed:
other: "创建表失败"
install:
create_config_failed:
other: "无法创建 config.yaml 文件。"
report:
spam:
name:
other: "垃圾信息"
desc:
other: "This post is an advertisement, or vandalism. It is not useful or relevant to the current topic."
other: "这个帖子是一个广告,或是破坏性行为。它对当前的主题没有用处,也不相关。"
rude:
name:
other: "粗鲁或辱骂的"
desc:
other: "A reasonable person would find this content inappropriate for respectful discourse."
other: "一个有理智的人都会认为这种内容不适合进行尊重性的讨论。"
duplicate:
name:
other: "重复信息"
desc:
other: "This question has been asked before and already has an answer."
other: "此问题以前就有人问过,而且已经有了答案。"
not_answer:
name:
other: "不是答案"
desc:
other: "This was posted as an answer, but it does not attempt to answer the question. It should possibly be an edit, a comment, another question, or deleted altogether."
other: "此帖子是作为一个答案发布的,但它并没有试图回答这个问题。总之,它可能应该是个编辑,评论,另一个问题或者被删除。"
not_need:
name:
other: "不再需要"
desc:
other: "This comment is outdated, conversational or not relevant to this post."
other: "此评论已过时,对话或与此帖子无关。"
other:
name:
other: "其他原因"
desc:
other: "This post requires staff attention for another reason not listed above."
other: "此帖子需要工作人员关注,因为是上述所列以外的其他理由。"
question:
close:
duplicate:
name:
other: "垃圾信息"
desc:
other: "This question has been asked before and already has an answer."
other: "此问题以前就有人问过,而且已经有了答案。"
guideline:
name:
other: "社区特定原因"
desc:
other: "This question doesn't meet a community guideline."
other: "此问题不符合社区准则。"
multiple:
name:
other: "需要细节或澄清"
desc:
other: "This question currently includes multiple questions in one. It should focus on one problem only."
other: "此问题目前涵盖多个问题。它应该只集中在一个问题上。"
other:
name:
other: "其他原因"
desc:
other: "This post requires another reason not listed above."
other: "这个帖子需要上面没有列出的另一个原因。"
operation_type:
asked:
other: "提问于"
answered:
other: "回答于"
modified:
other: "修改于"
notification:
action:
update_question:
@ -207,7 +216,7 @@ backend:
update_answer:
other: "更新了答案"
accept_answer:
other: "接受了答案"
other: "已接受的回答"
comment_question:
other: "评论了问题"
comment_answer:
@ -229,15 +238,7 @@ ui:
how_to_format:
title: 如何设定文本格式
desc: >-
<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>
<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: 下一页
@ -266,7 +267,7 @@ ui:
install: Answer 安装
upgrade: Answer 升级
maintenance: 网站维护
users: Users
users: 用户
notifications:
title: 通知
inbox: 收件箱
@ -290,7 +291,7 @@ ui:
class_diagram: 类图
state_diagram: 状态图
entity_relationship_diagram: ER 图
user_defined_diagram: User defined diagram
user_defined_diagram: 用户自定义图表
gantt_chart: 甘特图
pie_chart: 饼图
code:
@ -339,7 +340,7 @@ ui:
only_image: 只能上传图片文件。
max_size: 图片文件大小不能超过 4 MB。
desc:
label: 图片描述(可选)
label: 描述(可选)
tab_url: 网络图片
form_url:
fields:
@ -410,13 +411,13 @@ ui:
range: 不能超过 35 个字符
slug_name:
label: URL 固定链接
desc: '必须由 "a-z", "0-9", "+ # - ." 组成'
desc: '必须使用字符集 "a-z"、"0-9"、"+ # - ."'
msg:
empty: 不能为空
range: 不能超过 35 个字符
character: 包含非法字符
desc:
label: 标签描述(可选)
label: 描述(可选)
btn_cancel: 取消
btn_submit: 提交
tag_info:
@ -564,8 +565,7 @@ ui:
placeholder: 搜索
footer:
build_on: >-
Built on <1> Answer </1>- the open-source software that powers Q&A
communities<br />Made with love © 2022 Answer
基于<1>Answer</1>--为问答社区提供动力的开源软件。<br />Made with love © {{cc}}.
upload_img:
name: 更改图片
loading: 加载中...
@ -618,7 +618,7 @@ ui:
btn_cancel: 取消
btn_update: 更新电子邮件地址
send_success: >-
If an account matches <strong>{{mail}}</strong>, you should receive an email with instructions on how to reset your password shortly.
如果账户与<strong>{{mail}}</strong>相匹配,您应该很快就会收到一封电子邮件,说明如何重置您的密码。
email:
label: 新邮箱
msg:
@ -647,8 +647,8 @@ ui:
account: 账号
interface: 界面
profile:
heading: Profile
btn_name: Save
heading: 个人资料
btn_name: 保存
display_name:
label: 昵称
msg: 昵称不能为空
@ -666,7 +666,7 @@ ui:
custom: 自定义
btn_refresh: 刷新
custom_text: 您可以上传您的图片。
default: System
default: 系统
msg: 请上传头像
bio:
label: 关于我 (可选)
@ -678,12 +678,12 @@ ui:
label: 位置 (可选)
placeholder: "城市, 国家"
notification:
heading: Notifications
heading: 通知
email:
label: 邮件通知
radio: "你的提问有新的回答,评论,和其他"
account:
heading: Account
heading: 账号
change_email_btn: 更改邮箱
change_pass_btn: 更改密码
change_email_info: >-
@ -703,7 +703,7 @@ ui:
pass_confirm:
label: 确认新密码
interface:
heading: Interface
heading: 界面
lang:
label: 界面语言
text: 设置用户界面语言,在刷新页面后生效。
@ -711,7 +711,7 @@ ui:
update: 更新成功
update_password: 更改密码成功。
flag_success: 感谢您的标记,我们会尽快处理。
forbidden_operate_self: Forbidden to operate on yourself
forbidden_operate_self: 禁止自己操作
review: 您的修订将在审核通过后显示。
related_question:
title: 相关问题
@ -737,16 +737,16 @@ ui:
write_answer:
title: 你的回答
btn_name: 提交你的回答
add_another_answer: Add another answer
add_another_answer: 添加另一个答案
confirm_title: 继续回答
continue: 继续
confirm_info: >-
<p>您确定要提交一个新的回答吗?</p><p>您可以直接编辑和改善您之前的回答的。</p>
empty: 回答内容不能为空。
reopen:
title: Reopen this post
content: Are you sure you want to reopen?
success: This post has been reopened
title: 重新打开这个帖子
content: 确定要重新打开吗?
success: 这个帖子已被重新打开
delete:
title: 删除
question: >-
@ -811,6 +811,11 @@ ui:
confirm_new_email: 你的电子邮箱已更新
confirm_new_email_invalid: >-
抱歉,此验证链接已失效。也许是你的邮箱已经成功更改了?
unsubscribe:
page_title: 退订
success_title: 取消订阅成功
success_desc: 您已成功地从此订阅者列表中移除,并且将不会再收到我们的任何电子邮件。
link: 更改设置
question:
following_tags: 已关注的标签
edit: 编辑
@ -873,9 +878,9 @@ ui:
done: 完成
config_yaml_error: 无法创建配置文件
lang:
label: Please Choose a Language
label: 请选择一种语言
db_type:
label: Database Engine
label: 数据库引擎
db_username:
label: 用户名
placeholder: root
@ -890,17 +895,17 @@ ui:
msg: 数据库地址不能为空
db_name:
label: 数据库名
placeholder: answer
placeholder: 回答
msg: 数据库名称不能为空。
db_file:
label: Database File
label: 数据库文件
placeholder: /data/answer.db
msg: 数据库文件不能为空。
config_yaml:
title: 创建 config.yaml
label: 已创建 config.yaml 文件。
desc: >-
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.
您可以手动在 <1>/var/wwww/xxx/</1> 目录中创建<1>config.yaml</1> 文件并粘贴以下文本。
info: "完成后,点击“下一步”按钮。"
site_information: 站点信息
admin_account: 管理员账户
@ -909,52 +914,52 @@ ui:
msg: 站点名称不能为空。
site_url:
label: 站点地址URL
text: The address of your site.
text: 此网站的地址。
msg:
empty: 站点URL不能为空。
incorrect: 站点URL格式不正确。
contact_email:
label: 联系邮箱
text: Email address of key contact responsible for this site.
text: 负责本网站的主要联系人的电子邮件地址。
msg:
empty: Contact Email cannot be empty.
incorrect: Contact Email incorrect format.
empty: 联系人邮箱地址不能为空。
incorrect: 联系人邮箱地址不正确。
admin_name:
label: Name
msg: Name cannot be empty.
label: 昵称
msg: 昵称不能为空。
admin_password:
label: Password
label: 密码
text: >-
You will need this password to log in. Please store it in a secure location.
msg: Password cannot be empty.
您需要此密码才能登录。请将其存储在一个安全的位置。
msg: 密码不能为空。
admin_email:
label: Email
text: You will need this email to log in.
label: 邮箱
text: 您需要此电子邮件才能登录。
msg:
empty: Email cannot be empty.
incorrect: Email incorrect format.
ready_title: Your Answer is Ready!
empty: 邮箱不能为空。
incorrect: 邮箱格式不正确。
ready_title: 你的答案已经准备好了!
ready_desc: >-
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
如果你想改变更多的设置,请访问<1>管理员部分</1>;在网站菜单中找到它。
good_luck: "玩得愉快,祝您好运!"
warn_title: 警告
warn_desc: >-
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>.
文件<1>config.yaml</1>已存在。如果您需要重置此文件中的任何配置项,请先删除它。
install_now: 您可以尝试<1>现在安装</1>。
installed: 已安裝
installed_desc: >-
You appear to have already installed. To reinstall please clear your old database tables first.
您似乎已经安装过了。要重新安装,请先清除旧的数据库表。
db_failed: 数据连接异常!
db_failed_desc: >-
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.
这或者意味着数据库信息在 <1>config.yaml</1> 文件不正确,或者无法与数据库服务器建立联系。这可能意味着您的主机数据库服务器已关闭。
page_404:
desc: 页面不存在
desc: "很抱歉,此页面不存在。"
back_home: 回到主页
page_50X:
desc: 服务器遇到了一个错误,无法完成你的请求。
back_home: 回到主页
page_maintenance:
desc: "We are under maintenance, well be back soon."
desc: "我们正在进行维护,我们将很快回来。"
nav_menus:
dashboard: 后台管理
contents: 内容管理
@ -972,10 +977,10 @@ ui:
tos: 服务条款
privacy: 隐私政策
seo: SEO
customize: Customize
themes: Themes
customize: 自定义
themes: 主题
css-html: CSS/HTML
login: Login
login: 登录
admin:
admin_header:
title: 后台管理
@ -1001,6 +1006,7 @@ ui:
answer_links: 回答链接
documents: 文档
feedback: 用户反馈
support: 帮助
review: 审查
config: 配置
update_to: 更新到
@ -1025,11 +1031,11 @@ ui:
btn_cancel: 取消
btn_submit: 提交
normal_name: 正常
normal_desc: 正常状态的用户可以提问和回答。
normal_desc: 普通用户可以提问和回答。
suspended_name: 封禁
suspended_desc: 被封禁的用户将无法登录。
deleted_name: 删除
deleted_desc: 删除用户的个人信息,认证等等。
deleted_desc: "删除个人资料和身份验证关联。"
inactive_name: 不活跃
inactive_desc: 不活跃的用户必须重新验证邮箱。
confirm_title: 删除此用户
@ -1040,11 +1046,11 @@ ui:
status_modal:
title: "更改 {{ type }} 状态为..."
normal_name: 正常
normal_desc: 所有用户都可以访问
normal_desc: 所有用户都可以访问的普通帖子。
closed_name: 关闭
closed_desc: 不能回答,但仍然可以编辑、投票和评论。
closed_desc: "关闭的问题不能回答,但仍然可以编辑、投票和评论。"
deleted_name: 删除
deleted_desc: 所有获得/损失的声望将会恢复。
deleted_desc: 获得和丧失的所有信誉积分将被恢复。
btn_cancel: 取消
btn_submit: 提交
btn_next: 下一步
@ -1079,32 +1085,32 @@ ui:
change_status: 更改状态
change_role: 更改角色
show_logs: 显示日志
add_user: Add user
add_user: 添加用户
new_password_modal:
title: Set new password
title: 设置新密码
form:
fields:
password:
label: Password
text: The user will be logged out and need to login again.
msg: Password must be at 8 - 32 characters in length.
btn_cancel: Cancel
btn_submit: Submit
label: 密码
text: 用户将被注销,需要再次登录。
msg: 密码的长度必须是8-32个字符。
btn_cancel: 取消
btn_submit: 提交
user_modal:
title: Add new user
title: 添加新用户
form:
fields:
display_name:
label: Display Name
msg: display_name must be at 4 - 30 characters in length.
label: 昵称
msg: 昵称的长度必须是4-30个字符。
email:
label: Email
msg: Email is not valid.
label: 邮箱
msg: 电子邮箱无效。
password:
label: Password
msg: Password must be at 8 - 32 characters in length.
btn_cancel: Cancel
btn_submit: Submit
label: 密码
msg: 密码的长度必须是8-32个字符。
btn_cancel: 取消
btn_submit: 提交
questions:
page_title: 问题
normal: 正常
@ -1118,7 +1124,7 @@ ui:
action: 操作
change: 更改
filter:
placeholder: "Filter by title, question:id"
placeholder: "按标题过滤,问题:id"
answers:
page_title: 回答
normal: 正常
@ -1130,21 +1136,31 @@ ui:
action: 操作
change: 更改
filter:
placeholder: "Filter by title, answer:id"
placeholder: "按标题筛选,答案:id"
general:
page_title: 一般
name:
label: 站点名称
msg: 不能为空
text: 站点的名称作为站点的标题HTML 的 title 标签)。
text: "站点的名称作为站点的标题HTML 的 title 标签)。"
site_url:
label: 网站网址
msg: 网站网址不能为空。
validate: 请输入一个有效的 URL。
text: 此网站的地址。
short_desc:
label: 简短的站点标语 (可选)
msg: 不能为空
text: 简短的标语作为网站主页的标题HTML 的 title 标签)。
label: 简短网站描述(可选)
msg: 简短网站描述不能为空
text: "简短的标语作为网站主页的标题Html 的 title 标签)。"
desc:
label: 网站描述 (可选)
msg: 不能为空
text: 使用一句话描述本站作为网站的描述HTML 的 meta 标签)。
msg: 网站描述不能为空。
text: "使用一句话描述本站作为网站的描述Html 的 meta 标签)。"
contact_email:
label: 联系人邮箱
msg: 联系人邮箱不能为空。
validate: 联系人邮箱无效。
text: 负责本网站的主要联系人的电子邮件地址。
interface:
page_title: 界面
logo:
@ -1160,9 +1176,9 @@ ui:
msg: 不能为空
text: 设置用户界面语言,在刷新页面后生效。
time_zone:
label: Timezone
msg: Timezone cannot be empty.
text: Choose a city in the same timezone as you.
label: 时区
msg: 时区不能为空。
text: 选择一个与您相同时区的城市。
smtp:
page_title: SMTP
from_email:
@ -1198,97 +1214,97 @@ ui:
text: 提供用于接收测试邮件的邮箱地址。
msg: 地址无效
smtp_authentication:
label: Enable authentication
title: SMTP Authentication
label: 启用身份验证
title: SMTP身份验证
msg: 不能为空
"yes": "是"
"no": "否"
branding:
page_title: Branding
page_title: 品牌
logo:
label: Logo
msg: Logo cannot be empty.
text: The logo image at the top left of your site. Use a wide rectangular image with a height of 56 and an aspect ratio greater than 3:1. If left blank, the site title text will be shown.
label: 图标
msg: 图标不能为空。
text: 在你的网站左上方的Logo图标。使用一个高度为56长宽比大于3:1的宽长方形图像。如果留空将显示网站标题文本。
mobile_logo:
label: Mobile Logo (optional)
text: The logo used on mobile version of your site. Use a wide rectangular image with a height of 56. If left blank, the image from the “logo” setting will be used.
label: 移动端图标(可选)
text: 在你的网站的移动版上使用的标志。使用一个高度为56的宽矩形图像。如果留空将使用 "Logo"设置中的图像。
square_icon:
label: Square Icon
msg: Square icon cannot be empty.
text: Image used as the base for metadata icons. Should ideally be larger than 512x512.
label: 方形图标
msg: 方形图标不能为空。
text: 用作元数据图标的基础的图像。最好是大于512x512。
favicon:
label: Favicon (optional)
text: A favicon for your site. To work correctly over a CDN it must be a png. Will be resized to 32x32. If left blank, “square icon” will be used.
label: 收藏夹图标(可选)
text: 网站的图标。要在 CDN 正常工作,它必须是 png。 将调整大小到32x32。如果留空将使用“方形图标”。
legal:
page_title: Legal
page_title: 法律条款
terms_of_service:
label: Terms of Service
text: "You can add terms of service content here. If you already have a document hosted elsewhere, provide the full URL here."
label: 服务条款
text: "您可以在此添加服务内容的条款。如果您已经在别处托管了文档请在这里提供完整的URL。"
privacy_policy:
label: Privacy Policy
text: "You can add privacy policy content here. If you already have a document hosted elsewhere, provide the full URL here."
label: 隐私条款
text: "您可以在此添加隐私政策内容。如果您已经在别处托管了文档请在这里提供完整的URL。"
write:
page_title: Write
page_title: 编辑
recommend_tags:
label: Recommend Tags
text: "Please input tag slug above, one tag per line."
label: 推荐标签
text: "请输入以上标签,每行一个标签。"
required_tag:
title: Required Tag
label: Set recommend tag as required
text: "Every new question must have at least one recommend tag."
title: 必需的标签
label: 根据需要设置推荐标签
text: "每个新问题必须至少有一个推荐标签。"
reserved_tags:
label: Reserved Tags
text: "Reserved tags can only be added to a post by moderator."
label: 保留标签
text: "保留的标签只能由版主添加到一个帖子中。"
seo:
page_title: SEO
page_title: 搜索引擎优化
permalink:
label: Permalink
text: Custom URL structures can improve the usability, and forward-compatibility of your links.
label: 固定链接
text: 自定义URL结构可以提高可用性以及你的链接的向前兼容性。
robots:
label: robots.txt
text: This will permanently override any related site settings.
text: 这将永久覆盖任何相关的网站设置。
themes:
page_title: Themes
page_title: 主题
themes:
label: Themes
text: Select an existing theme.
label: 主题
text: 选择一个现有主题。
navbar_style:
label: Navbar Style
text: Select an existing theme.
label: 导航栏样式
text: 选择一个现有主题。
primary_color:
label: Primary Color
text: Modify the colors used by your themes
label: 主色调
text: 修改您主题使用的颜色
css_and_html:
page_title: CSS and HTML
page_title: CSS HTML
custom_css:
label: Custom CSS
text: This will insert as <link>
label: 自定义CSS
text: 这将在 <link> 之前插入
head:
label: Head
text: This will insert before </head>
label: 头部
text: 这将在 </head> 之前插入
header:
label: Header
text: This will insert after <body>
label: 标题
text: 这将在 <body> 之前插入
footer:
label: Footer
text: This will insert before </html>.
label: 页脚
text: 这将在 </html> 之前插入
login:
page_title: Login
page_title: 登录
membership:
title: Membership
label: Allow new registrations
text: Turn off to prevent anyone from creating a new account.
title: 会员
label: 允许新注册
text: 关闭以防止任何人创建新帐户。
private:
title: Private
label: Login required
text: Only logged in users can access this community.
title: 非公开的
label: 需要登录
text: 只有登录用户才能访问这个社区。
form:
empty: cannot be empty
invalid: is invalid
btn_submit: Save
not_found_props: "Required property {{ key }} not found."
empty: 不能为空
invalid: 是无效的
btn_submit: 保存
not_found_props: "所需属性 {{ key }} 未找到。"
page_review:
review: Review
review: 评论
proposed: 提案
question_edit: 问题编辑
answer_edit: 回答编辑
@ -1326,11 +1342,11 @@ ui:
comment: 评论
no_data: "空空如也"
users:
title: Users
users_with_the_most_reputation: Users with the highest reputation scores
users_with_the_most_vote: Users who voted the most
staffs: Our community staff
reputation: reputation
votes: votes
title: 用户
users_with_the_most_reputation: 信誉积分最高的用户
users_with_the_most_vote: 投票最多的用户
staffs: 我们的社区工作人员
reputation: 声望值
votes: 投票

View File

@ -4,11 +4,11 @@ backend:
success:
other: "成功!"
unknown:
other: "Unknown error."
other: "未知的錯誤。"
request_format_error:
other: "Request format is not valid."
other: "請求的格式無效。"
unauthorized_error:
other: "Unauthorized."
other: "未授權。"
database_error:
other: "Data server error."
role:
@ -29,13 +29,13 @@ backend:
email:
other: "Email"
password:
other: "Password"
other: "密碼"
email_or_password_wrong_error:
other: "Email and password do not match."
other: "電子郵箱和密碼不匹配。"
error:
admin:
email_or_password_wrong:
other: Email and password do not match.
other: 電子郵箱和密碼不匹配。
answer:
not_found:
other: "Answer do not found."
@ -70,22 +70,22 @@ backend:
not_found:
other: "Object not found."
verification_failed:
other: "Verification failed."
other: "驗證失敗。"
email_or_password_incorrect:
other: "Email and password do not match."
other: "電子郵箱和密碼不匹配。"
old_password_verification_failed:
other: "The old password verification failed"
other: "舊密碼驗證失敗"
new_password_same_as_previous_setting:
other: "The new password is the same as the previous one."
other: "新密碼與先前的一樣。"
question:
not_found:
other: "Question not found."
other: "找不到問題。"
cannot_deleted:
other: "No permission to delete."
other: "沒有刪除的權限。"
cannot_close:
other: "No permission to close."
other: "沒有關閉的權限。"
cannot_update:
other: "No permission to update."
other: "沒有更新的權限。"
rank:
fail_to_meet_the_condition:
other: "Rank fail to meet the condition."
@ -107,9 +107,12 @@ backend:
other: "No permission to update."
cannot_set_synonym_as_itself:
other: "You cannot set the synonym of the current tag as itself."
smtp:
config_from_name_cannot_be_email:
other: "The From Name cannot be a email address."
theme:
not_found:
other: "Theme not found."
other: "未找到主題。"
revision:
review_underway:
other: "Can't edit currently, there is a version in the review queue."
@ -129,21 +132,21 @@ backend:
other: "Username is already in use."
set_avatar:
other: "Avatar set failed."
config:
read_config_failed:
other: "Read config failed"
database:
connection_failed:
other: "Database connection failed"
create_table_failed:
other: "Create table failed"
install:
create_config_failed:
other: "Cant create the config.yaml file."
cannot_update_your_role:
other: "You cannot modify your role."
not_allowed_registration:
other: "Currently the site is not open for registration"
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:
spam:
name:
@ -197,6 +200,13 @@ backend:
other: "something else"
desc:
other: "This post requires another reason not listed above."
operation_type:
asked:
other: "asked"
answered:
other: "answered"
modified:
other: "modified"
notification:
action:
update_question:
@ -801,6 +811,11 @@ ui:
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?
unsubscribe:
page_title: Unsubscribe
success_title: Unsubscribe Successful
success_desc: You have been successfully removed from this subscriber list and wont receive any further emails from us.
link: Change settings
question:
following_tags: Following Tags
edit: Edit
@ -991,6 +1006,7 @@ ui:
answer_links: Answer Links
documents: Documents
feedback: Feedback
support: Support
review: Review
config: Config
update_to: Update to

View File

@ -0,0 +1,7 @@
package constant
import "time"
const (
CommentEditDeadline = time.Minute * 5
)

View File

@ -7,10 +7,10 @@ import (
"github.com/answerdev/answer/pkg/dir"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/segmentfault/pacman/cache"
"github.com/segmentfault/pacman/contrib/cache/memory"
"github.com/segmentfault/pacman/log"
_ "modernc.org/sqlite"
"xorm.io/core"
"xorm.io/xorm"
ormlog "xorm.io/xorm/log"
@ -43,6 +43,7 @@ func NewDB(debug bool, dataConf *Database) (*xorm.Engine, error) {
if err := dir.CreateDirIfNotExist(dbFileDir); err != nil {
log.Errorf("create database dir failed: %s", err)
}
dataConf.MaxOpenConn = 1
}
engine, err := xorm.NewEngine(dataConf.Driver, dataConf.Connection)
if err != nil {

View File

@ -21,7 +21,7 @@ type RespBody struct {
// TrMsg translate the reason cause as a message
func (r *RespBody) TrMsg(lang i18n.Language) *RespBody {
if len(r.Message) == 0 {
r.Message = translator.GlobalTrans.Tr(lang, r.Reason)
r.Message = translator.Tr(lang, r.Reason)
}
return r
}

View File

@ -4,10 +4,35 @@ import (
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/handler"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/i18n"
)
var (
langMapping = map[i18n.Language]bool{
i18n.LanguageChinese: true,
i18n.LanguageChineseTraditional: true,
i18n.LanguageEnglish: true,
i18n.LanguageGerman: true,
i18n.LanguageSpanish: true,
i18n.LanguageFrench: true,
i18n.LanguageItalian: true,
i18n.LanguageJapanese: true,
i18n.LanguageKorean: true,
i18n.LanguagePortuguese: true,
i18n.LanguageRussian: true,
i18n.LanguageVietnamese: true,
}
)
// ExtractAndSetAcceptLanguage extract accept language from header and set to context
func ExtractAndSetAcceptLanguage(ctx *gin.Context) {
// The language of our front-end configuration, like en_US
lang := handler.GetLang(ctx)
ctx.Set(constant.AcceptLanguageFlag, lang)
if langMapping[lang] {
ctx.Set(constant.AcceptLanguageFlag, lang)
return
}
// default language
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
}

View File

@ -16,6 +16,7 @@ const (
const (
EmailOrPasswordWrong = "error.object.email_or_password_incorrect"
CommentNotFound = "error.comment.not_found"
CommentCannotEditAfterDeadline = "error.comment.cannot_edit_after_deadline"
QuestionNotFound = "error.question.not_found"
QuestionCannotDeleted = "error.question.cannot_deleted"
QuestionCannotClose = "error.question.cannot_close"

View File

@ -106,3 +106,12 @@ func CheckLanguageIsValid(lang string) bool {
}
return false
}
// Tr use language to translate data. If this language translation is not available, return default english translation.
func Tr(lang i18n.Language, data string) string {
translation := GlobalTrans.Tr(lang, data)
if translation == data {
return GlobalTrans.Tr(i18n.DefaultLanguage, data)
}
return translation
}

View File

@ -23,6 +23,7 @@ import (
chineseTraditional "github.com/go-playground/locales/zh_Hant_TW"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
"github.com/go-playground/validator/v10/non-standard/validators"
"github.com/go-playground/validator/v10/translations/en"
"github.com/go-playground/validator/v10/translations/es"
"github.com/go-playground/validator/v10/translations/fr"
@ -99,10 +100,11 @@ func getTran(lo locales.Translator) ut.Translator {
func createDefaultValidator(la i18n.Language) *validator.Validate {
validate := validator.New()
_ = validate.RegisterValidation("notblank", validators.NotBlank)
validate.RegisterTagNameFunc(func(fld reflect.StructField) (res string) {
defer func() {
if len(res) > 0 {
res = translator.GlobalTrans.Tr(la, res)
res = translator.Tr(la, res)
}
}()
if jsonTag := fld.Tag.Get("json"); len(jsonTag) > 0 {
@ -168,7 +170,7 @@ func (m *MyValidator) Check(value interface{}) (errFields []*FormErrorField, err
return nil, nil
}
for _, errField := range errFields {
errField.ErrorMsg = translator.GlobalTrans.Tr(m.Lang, errField.ErrorMsg)
errField.ErrorMsg = translator.Tr(m.Lang, errField.ErrorMsg)
}
return errFields, err
}

View File

@ -12,8 +12,9 @@ import (
)
const (
DefaultConfigFileName = "config.yaml"
DefaultCacheFileName = "cache.db"
DefaultConfigFileName = "config.yaml"
DefaultCacheFileName = "cache.db"
DefaultReservedUsernamesConfigFileName = "reserved-usernames.json"
)
var (
@ -40,6 +41,7 @@ func InstallAllInitialEnvironment(dataDirPath string) {
FormatAllPath(dataDirPath)
installUploadDir()
installI18nBundle()
installReservedUsernames()
fmt.Println("install all initial environment done")
}
@ -112,3 +114,16 @@ func installI18nBundle() {
}
}
}
func installReservedUsernames() {
reservedUsernamesJsonFilePath := filepath.Join(ConfigFileDir, DefaultReservedUsernamesConfigFileName)
if !dir.CheckFileExist(reservedUsernamesJsonFilePath) {
err := writer.WriteFile(reservedUsernamesJsonFilePath, string(configs.ReservedUsernames))
if err != nil {
fmt.Printf("[%s] write file fail: %s\n", DefaultReservedUsernamesConfigFileName, err)
} else {
fmt.Printf("[%s] write file success\n", DefaultReservedUsernamesConfigFileName)
}
return
}
}

View File

@ -111,6 +111,7 @@ func (cc *CommentController) UpdateComment(ctx *gin.Context) {
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetIsAdminFromContext(ctx)
can, err := cc.rankService.CheckOperationPermission(ctx, req.UserID, permission.CommentEdit, req.CommentID)
if err != nil {
handler.HandleResponse(ctx, err, nil)

View File

@ -1,10 +1,9 @@
package controller
import (
"context"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/middleware"
"github.com/answerdev/answer/internal/base/pager"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/validator"
"github.com/answerdev/answer/internal/entity"
@ -31,7 +30,7 @@ func NewQuestionController(questionService *service.QuestionService, rankService
// RemoveQuestion delete question
// @Summary delete question
// @Description delete question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -62,7 +61,7 @@ func (qc *QuestionController) RemoveQuestion(ctx *gin.Context) {
// CloseQuestion Close question
// @Summary Close question
// @Description Close question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -92,7 +91,7 @@ func (qc *QuestionController) CloseQuestion(ctx *gin.Context) {
// ReopenQuestion reopen question
// @Summary reopen question
// @Description reopen question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -119,10 +118,10 @@ func (qc *QuestionController) ReopenQuestion(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil)
}
// GetQuestion godoc
// @Summary GetQuestion Question
// @Description GetQuestion Question
// @Tags api-question
// GetQuestion get question details
// @Summary get question details
// @Description get question details
// @Tags Question
// @Security ApiKeyAuth
// @Accept json
// @Produce json
@ -161,7 +160,7 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
// SimilarQuestion godoc
// @Summary Search Similar Question
// @Description Search Similar Question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Param question_id query string true "question_id" default()
@ -181,65 +180,34 @@ func (qc *QuestionController) SimilarQuestion(ctx *gin.Context) {
})
}
// Index godoc
// @Summary SearchQuestionList
// @Description SearchQuestionList <br> "order" Enums(newest, active,frequent,score,unanswered)
// @Tags api-question
// QuestionPage get questions by page
// @Summary get questions by page
// @Description get questions by page
// @Tags Question
// @Accept json
// @Produce json
// @Param data body schema.QuestionSearch true "QuestionSearch"
// @Success 200 {string} string ""
// @Param data body schema.QuestionPageReq true "QuestionPageReq"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.QuestionPageResp}}
// @Router /answer/api/v1/question/page [get]
func (qc *QuestionController) Index(ctx *gin.Context) {
req := &schema.QuestionSearch{}
func (qc *QuestionController) QuestionPage(ctx *gin.Context) {
req := &schema.QuestionPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
userID := middleware.GetLoginUserIDFromContext(ctx)
list, count, err := qc.questionService.SearchList(ctx, req, userID)
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
questions, total, err := qc.questionService.GetQuestionPage(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, gin.H{
"list": list,
"count": count,
})
}
// SearchList godoc
// @Summary SearchQuestionList
// @Description SearchQuestionList
// @Tags api-question
// @Accept json
// @Produce json
// @Param data body schema.QuestionSearch true "QuestionSearch"
// @Router /answer/api/v1/question/search [post]
// @Success 200 {string} string ""
func (qc *QuestionController) SearchList(c *gin.Context) {
Request := new(schema.QuestionSearch)
err := c.BindJSON(Request)
if err != nil {
handler.HandleResponse(c, err, nil)
return
}
ctx := context.Background()
userID := middleware.GetLoginUserIDFromContext(c)
list, count, err := qc.questionService.SearchList(ctx, Request, userID)
if err != nil {
handler.HandleResponse(c, err, nil)
return
}
handler.HandleResponse(c, nil, gin.H{
"list": list,
"count": count,
})
handler.HandleResponse(ctx, nil, pager.NewPageModel(total, questions))
}
// AddQuestion add question
// @Summary add question
// @Description add question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -309,7 +277,7 @@ func (qc *QuestionController) AddQuestion(ctx *gin.Context) {
// UpdateQuestion update question
// @Summary update question
// @Description update question
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -366,7 +334,7 @@ func (qc *QuestionController) UpdateQuestion(ctx *gin.Context) {
// CloseMsgList close question msg list
// @Summary close question msg list
// @Description close question msg list
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -380,7 +348,7 @@ func (qc *QuestionController) CloseMsgList(ctx *gin.Context) {
// SearchByTitleLike add question title like
// @Summary add question title like
// @Description add question title like
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -397,7 +365,7 @@ func (qc *QuestionController) SearchByTitleLike(ctx *gin.Context) {
// UserTop godoc
// @Summary UserTop
// @Description UserTop
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@ -417,7 +385,7 @@ func (qc *QuestionController) UserTop(ctx *gin.Context) {
// UserList godoc
// @Summary UserList
// @Description UserList
// @Tags api-question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth

View File

@ -91,8 +91,8 @@ func (tc *TemplateController) SiteInfo(ctx *gin.Context) *schema.TemplateSiteInf
// Index question list
func (tc *TemplateController) Index(ctx *gin.Context) {
req := &schema.QuestionSearch{
Order: "newest",
req := &schema.QuestionPageReq{
OrderCond: "newest",
}
if handler.BindAndCheck(ctx, req) {
tc.Page404(ctx)
@ -124,8 +124,8 @@ func (tc *TemplateController) Index(ctx *gin.Context) {
}
func (tc *TemplateController) QuestionList(ctx *gin.Context) {
req := &schema.QuestionSearch{
Order: "newest",
req := &schema.QuestionPageReq{
OrderCond: "newest",
}
if handler.BindAndCheck(ctx, req) {
tc.Page404(ctx)
@ -139,6 +139,9 @@ func (tc *TemplateController) QuestionList(ctx *gin.Context) {
}
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/questions", siteInfo.General.SiteUrl)
if page > 1 {
siteInfo.Canonical = fmt.Sprintf("%s/questions?page=%d", siteInfo.General.SiteUrl, page)
}
UrlUseTitle := false
if siteInfo.SiteSeo.PermaLink == schema.PermaLinkQuestionIDAndTitle {
@ -327,6 +330,9 @@ func (tc *TemplateController) TagList(ctx *gin.Context) {
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/tags", siteInfo.General.SiteUrl)
if req.Page > 1 {
siteInfo.Canonical = fmt.Sprintf("%s/tags?page=%d", siteInfo.General.SiteUrl, req.Page)
}
siteInfo.Title = fmt.Sprintf("%s - %s", "Tags", siteInfo.General.Name)
tc.html(ctx, http.StatusOK, "tags.html", siteInfo, gin.H{
"page": page,
@ -353,6 +359,9 @@ func (tc *TemplateController) TagInfo(ctx *gin.Context) {
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/tags/%s", siteInfo.General.SiteUrl, tag)
if req.Page > 1 {
siteInfo.Canonical = fmt.Sprintf("%s/tags/%s?page=%d", siteInfo.General.SiteUrl, tag, req.Page)
}
siteInfo.Description = htmltext.FetchExcerpt(taginifo.ParsedText, "...", 240)
if len(taginifo.ParsedText) == 0 {
siteInfo.Description = "The tag has no description."
@ -437,6 +446,7 @@ func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteI
data["HeadCode"] = siteInfo.CustomCssHtml.CustomHead
data["HeaderCode"] = siteInfo.CustomCssHtml.CustomHeader
data["FooterCode"] = siteInfo.CustomCssHtml.CustomFooter
data["Version"] = constant.Version
_, ok := data["path"]
if !ok {
data["path"] = ""

View File

@ -11,8 +11,8 @@ import (
"github.com/segmentfault/pacman/log"
)
func (t *TemplateRenderController) Index(ctx *gin.Context, req *schema.QuestionSearch) ([]*schema.QuestionInfo, int64, error) {
return t.questionService.SearchList(ctx, req, req.UserID)
func (t *TemplateRenderController) Index(ctx *gin.Context, req *schema.QuestionPageReq) ([]*schema.QuestionPageResp, int64, error) {
return t.questionService.GetQuestionPage(ctx, req)
}
func (t *TemplateRenderController) QuestionDetail(ctx *gin.Context, id string) (resp *schema.QuestionInfo, err error) {

View File

@ -15,19 +15,20 @@ func (q *TemplateRenderController) TagList(ctx context.Context, req *schema.GetT
return
}
func (q *TemplateRenderController) TagInfo(ctx context.Context, req *schema.GetTamplateTagInfoReq) (resp *schema.GetTagResp, questionList []*schema.QuestionInfo, questionCount int64, err error) {
func (q *TemplateRenderController) TagInfo(ctx context.Context, req *schema.GetTamplateTagInfoReq) (resp *schema.GetTagResp, questionList []*schema.QuestionPageResp, questionCount int64, err error) {
dto := &schema.GetTagInfoReq{}
_ = copier.Copy(dto, req)
resp, err = q.tagService.GetTagInfo(ctx, dto)
if err != nil {
return
}
searchQuestion := &schema.QuestionSearch{}
searchQuestion := &schema.QuestionPageReq{}
searchQuestion.Page = req.Page
searchQuestion.PageSize = req.PageSize
searchQuestion.Order = "newest"
searchQuestion.OrderCond = "newest"
searchQuestion.Tag = req.Name
questionList, questionCount, err = q.questionService.SearchList(ctx, searchQuestion, "")
searchQuestion.LoginUserID = req.UserID
questionList, questionCount, err = q.questionService.GetQuestionPage(ctx, searchQuestion)
if err != nil {
return
}

View File

@ -3,7 +3,9 @@ package controller
import (
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/uploader"
"github.com/answerdev/answer/pkg/converter"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
@ -63,3 +65,21 @@ func (uc *UploadController) UploadFile(ctx *gin.Context) {
}
handler.HandleResponse(ctx, err, url)
}
// PostRender render post content
// @Summary render post content
// @Description render post content
// @Tags Upload
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.PostRenderReq true "PostRenderReq"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/post/render [post]
func (uc *UploadController) PostRender(ctx *gin.Context) {
req := &schema.PostRenderReq{}
if handler.BindAndCheck(ctx, req) {
return
}
handler.HandleResponse(ctx, nil, converter.Markdown2HTML(req.Content))
}

View File

@ -113,7 +113,7 @@ func (uc *UserController) UserEmailLogin(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
@ -124,7 +124,7 @@ func (uc *UserController) UserEmailLogin(ctx *gin.Context) {
_, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeLogin, ctx.ClientIP())
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "e_mail",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.EmailOrPasswordWrong),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.EmailOrPasswordWrong),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.EmailOrPasswordWrong), errFields)
return
@ -151,14 +151,14 @@ func (uc *UserController) RetrievePassWord(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
_, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeFindPass, ctx.ClientIP())
code, err := uc.userService.RetrievePassWord(ctx, req)
handler.HandleResponse(ctx, err, code)
_, err := uc.userService.RetrievePassWord(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// UseRePassWord godoc
@ -236,7 +236,7 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
@ -245,7 +245,8 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
resp, errFields, err := uc.userService.UserRegisterByEmail(ctx, req)
if len(errFields) > 0 {
for _, field := range errFields {
field.ErrorMsg = translator.GlobalTrans.Tr(handler.GetLang(ctx), field.ErrorMsg)
field.ErrorMsg = translator.
Tr(handler.GetLang(ctx), field.ErrorMsg)
}
handler.HandleResponse(ctx, err, errFields)
} else {
@ -312,7 +313,7 @@ func (uc *UserController) UserVerifyEmailSend(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
@ -350,7 +351,7 @@ func (uc *UserController) UserModifyPassWord(ctx *gin.Context) {
if !oldPassVerification {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "old_pass",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.OldPasswordVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.OldPasswordVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.OldPasswordVerificationFailed), errFields)
return
@ -358,7 +359,7 @@ func (uc *UserController) UserModifyPassWord(ctx *gin.Context) {
if req.OldPass == req.Pass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "pass",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.NewPasswordSameAsPreviousSetting),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.NewPasswordSameAsPreviousSetting),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.NewPasswordSameAsPreviousSetting), errFields)
return
@ -384,8 +385,11 @@ func (uc *UserController) UserUpdateInfo(ctx *gin.Context) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := uc.userService.UpdateInfo(ctx, req)
handler.HandleResponse(ctx, err, nil)
errFields, err := uc.userService.UpdateInfo(ctx, req)
for _, field := range errFields {
field.ErrorMsg = translator.Tr(handler.GetLang(ctx), field.ErrorMsg)
}
handler.HandleResponse(ctx, err, errFields)
}
// UserUpdateInterface update user interface config
@ -488,7 +492,7 @@ func (uc *UserController) UserChangeEmailSendCode(ctx *gin.Context) {
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
ErrorMsg: translator.Tr(handler.GetLang(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return

View File

@ -22,11 +22,6 @@ var AdminQuestionSearchStatusIntToString = map[int]string{
QuestionStatusDeleted: "deleted",
}
type QuestionTag struct {
Question `xorm:"extends"`
TagRel `xorm:"extends"`
}
// Question question
type Question struct {
ID string `xorm:"not null pk BIGINT(20) id"`

View File

@ -5,6 +5,10 @@ import (
"net/url"
"strings"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/validator"
"github.com/answerdev/answer/pkg/checker"
"github.com/segmentfault/pacman/errors"
"xorm.io/xorm/schemas"
)
@ -77,11 +81,23 @@ type InitBaseInfoReq struct {
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"`
AdminName string `validate:"required,gt=3,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) Check() (errFields []*validator.FormErrorField, err error) {
if checker.IsInvalidUsername(r.AdminName) {
errField := &validator.FormErrorField{
ErrorField: "name",
ErrorMsg: reason.UsernameInvalid,
}
errFields = append(errFields, errField)
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
return
}
func (r *InitBaseInfoReq) FormatSiteUrl() {
parsedUrl, err := url.Parse(r.SiteURL)
if err != nil {

View File

@ -261,7 +261,7 @@ func initConfigTable(engine *xorm.Engine) error {
{ID: 30, Key: "answer.vote_up", Value: `0`},
{ID: 31, Key: "answer.vote_up_cancel", Value: `0`},
{ID: 32, Key: "question.follow", Value: `0`},
{ID: 33, Key: "email.config", Value: `{"from_name":"","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: 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.","new_answer_title":"[{{.SiteName}}] {{.DisplayName}} answered your question","new_answer_body":"<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>","new_comment_title":"[{{.SiteName}}] {{.DisplayName}} commented on your post","new_comment_body":"<strong><a href='{{.CommentUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.CommentSummary}}</blockquote><br>\n<a href='{{.CommentUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"}`},
{ID: 35, Key: "tag.follow", Value: `0`},
{ID: 36, Key: "rank.question.add", Value: `1`},
{ID: 37, Key: "rank.question.edit", Value: `200`},

View File

@ -55,6 +55,7 @@ var migrations = []Migration{
NewMigration("add activity timeline", addActivityTimeline, false),
NewMigration("add user role", addRoleFeatures, false),
NewMigration("add theme and private mode", addThemeAndPrivateMode, true),
NewMigration("add new answer notification", addNewAnswerNotification, true),
NewMigration("add plugin", addPlugin, false),
}

View File

@ -1,34 +1,41 @@
package migrations
import (
"encoding/json"
"fmt"
"github.com/answerdev/answer/internal/entity"
"github.com/segmentfault/pacman/log"
"xorm.io/xorm"
)
func addPlugin(x *xorm.Engine) error {
defaultConfigTable := []*entity.Config{
{ID: 118, Key: "plugin.status", Value: `{}`},
func addNewAnswerNotification(x *xorm.Engine) error {
cond := &entity.Config{Key: "email.config"}
exists, err := x.Get(cond)
if err != nil {
return fmt.Errorf("get email config failed: %w", err)
}
for _, c := range defaultConfigTable {
exist, err := x.Get(&entity.Config{ID: c.ID, Key: c.Key})
if !exists {
// This should be impossible except that the config was deleted manually by user.
_, err = x.InsertOne(&entity.Config{
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.","new_answer_title":"[{{.SiteName}}] {{.DisplayName}} answered your question","new_answer_body":"<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>","new_comment_title":"[{{.SiteName}}] {{.DisplayName}} commented on your post","new_comment_body":"<strong><a href='{{.CommentUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.CommentSummary}}</blockquote><br>\n<a href='{{.CommentUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"}`,
})
if err != nil {
return fmt.Errorf("get config failed: %w", err)
}
if exist {
if _, err = x.Update(c, &entity.Config{ID: c.ID, Key: c.Key}); err != nil {
log.Errorf("update %+v config failed: %s", c, err)
return fmt.Errorf("update config failed: %w", err)
}
continue
}
if _, err = x.Insert(&entity.Config{ID: c.ID, Key: c.Key, Value: c.Value}); err != nil {
log.Errorf("insert %+v config failed: %s", c, err)
return fmt.Errorf("add config failed: %w", err)
return fmt.Errorf("add email config failed: %v", err)
}
}
return x.Sync(new(entity.PluginConfig))
m := make(map[string]interface{})
_ = json.Unmarshal([]byte(cond.Value), &m)
m["new_answer_title"] = "[{{.SiteName}}] {{.DisplayName}} answered your question"
m["new_answer_body"] = "<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"
m["new_comment_title"] = "[{{.SiteName}}] {{.DisplayName}} commented on your post"
m["new_comment_body"] = "<strong><a href='{{.CommentUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.CommentSummary}}</blockquote><br>\n<a href='{{.CommentUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"
val, _ := json.Marshal(m)
_, err = x.ID(cond.ID).Update(&entity.Config{Value: string(val)})
if err != nil {
return fmt.Errorf("update email config failed: %v", err)
}
return nil
}

View File

@ -88,7 +88,7 @@ func (ar *ActivityRepo) GetActivity(ctx context.Context, session *xorm.Session,
func (ar *ActivityRepo) GetUserIDObjectIDActivitySum(ctx context.Context, userID, objectID string) (int, error) {
sum := &entity.ActivityRankSum{}
_, err := ar.data.DB.Table(entity.Activity{}.TableName()).
Select("sum(rank) as rank").
Select("sum(`rank`) as `rank`").
Where("user_id =?", userID).
And("object_id = ?", objectID).
And("cancelled =0").
@ -113,7 +113,7 @@ func (ar *ActivityRepo) AddActivity(ctx context.Context, activity *entity.Activi
func (ar *ActivityRepo) GetUsersWhoHasGainedTheMostReputation(
ctx context.Context, startTime, endTime time.Time, limit int) (rankStat []*entity.ActivityUserRankStat, err error) {
rankStat = make([]*entity.ActivityUserRankStat, 0)
session := ar.data.DB.Select("user_id, SUM(rank) AS rank_amount").Table("activity")
session := ar.data.DB.Select("user_id, SUM(`rank`) AS rank_amount").Table("activity")
session.Where("has_rank = 1 AND cancelled = 0")
session.Where("created_at >= ?", startTime)
session.Where("created_at <= ?", endTime)

View File

@ -11,6 +11,7 @@ import (
collectioncommon "github.com/answerdev/answer/internal/service/collection_common"
"github.com/answerdev/answer/internal/service/unique"
"github.com/segmentfault/pacman/errors"
"xorm.io/xorm"
)
// collectionRepo collection repository
@ -29,15 +30,28 @@ func NewCollectionRepo(data *data.Data, uniqueIDRepo unique.UniqueIDRepo) collec
// AddCollection add collection
func (cr *collectionRepo) AddCollection(ctx context.Context, collection *entity.Collection) (err error) {
id, err := cr.uniqueIDRepo.GenUniqueIDStr(ctx, collection.TableName())
if err == nil {
collection.ID = id
_, err = cr.data.DB.Insert(collection)
_, err = cr.data.DB.Transaction(func(session *xorm.Session) (result any, err error) {
var has bool
dbcollection := &entity.Collection{}
result = nil
has, err = session.Where("user_id = ? and object_id = ?", collection.UserID, collection.ObjectID).Get(dbcollection)
if err != nil {
return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
return
}
}
return nil
if has {
return
}
id, err := cr.uniqueIDRepo.GenUniqueIDStr(ctx, collection.TableName())
if err == nil {
collection.ID = id
_, err = session.Insert(collection)
if err != nil {
return result, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
}
return
})
return err
}
// RemoveCollection delete collection

View File

@ -205,70 +205,40 @@ func (qr *questionRepo) GetQuestionIDsPage(ctx context.Context, page, pageSize i
return questionIDList, nil
}
// GetQuestionPage get question page
func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, question *entity.Question) (questionList []*entity.Question, total int64, err error) {
// GetQuestionPage query question page
func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string) (
questionList []*entity.Question, total int64, err error) {
questionList = make([]*entity.Question, 0)
total, err = pager.Help(page, pageSize, questionList, question, qr.data.DB.NewSession())
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
return
}
// SearchList
func (qr *questionRepo) SearchList(ctx context.Context, search *schema.QuestionSearch) ([]*entity.QuestionTag, int64, error) {
var count int64
var err error
rows := make([]*entity.QuestionTag, 0)
if search.Page > 0 {
search.Page = search.Page - 1
} else {
search.Page = 0
session := qr.data.DB.Where("question.status = ? OR question.status = ?",
entity.QuestionStatusAvailable, entity.QuestionStatusClosed)
if len(tagID) > 0 {
session.Join("LEFT", "tag_rel", "question.id = tag_rel.object_id")
session.And("tag_rel.tag_id = ?", tagID)
session.And("tag_rel.status = ?", entity.TagRelStatusAvailable)
}
if search.PageSize == 0 {
search.PageSize = constant.DefaultPageSize
if len(userID) > 0 {
session.And("question.user_id = ?", userID)
}
offset := search.Page * search.PageSize
session := qr.data.DB.Table("question")
if len(search.TagIDs) > 0 {
session = session.Join("LEFT", "tag_rel", "question.id = tag_rel.object_id")
session = session.And("tag_rel.tag_id =?", search.TagIDs[0])
// session = session.In("tag_rel.tag_id ", search.TagIDs)
session = session.And("tag_rel.status =?", entity.TagRelStatusAvailable)
}
if len(search.UserID) > 0 {
session = session.And("question.user_id = ?", search.UserID)
}
session = session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed})
// if search.Status > 0 {
// session = session.And("question.status = ?", search.Status)
// }
// switch
// newest, active,frequent,score,unanswered
switch search.Order {
switch orderCond {
case "newest":
session = session.OrderBy("question.created_at desc")
session.OrderBy("question.created_at DESC")
case "active":
session = session.OrderBy("question.post_update_time desc,question.updated_at desc")
session.OrderBy("question.post_update_time DESC, question.updated_at DESC")
case "frequent":
session = session.OrderBy("question.view_count desc")
session.OrderBy("question.view_count DESC")
case "score":
session = session.OrderBy("question.vote_count desc,question.view_count desc")
session.OrderBy("question.vote_count DESC, question.view_count DESC")
case "unanswered":
session = session.And("question.last_answer_id = 0")
session = session.OrderBy("question.created_at desc")
session.Where("question.last_answer_id = 0")
session.OrderBy("question.created_at DESC")
}
session = session.Limit(search.PageSize, offset)
session = session.Select("question.id,question.user_id,last_edit_user_id,question.title,question.original_text,question.parsed_text,question.status,question.view_count,question.unique_view_count,question.vote_count,question.answer_count,question.collection_count,question.follow_count,question.accepted_answer_id,question.last_answer_id,question.created_at,question.updated_at,question.post_update_time,question.revision_id")
count, err = session.FindAndCount(&rows)
total, err = pager.Help(page, pageSize, &questionList, &entity.Question{}, session)
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
return rows, count, err
}
return rows, count, nil
return questionList, total, err
}
func (qr *questionRepo) AdminSearchList(ctx context.Context, search *schema.AdminQuestionSearch) ([]*entity.Question, int64, error) {

View File

@ -29,7 +29,7 @@ var (
"`question`.`id` as `question_id`",
"`title`",
"`parsed_text`",
"`question`.`created_at`",
"`question`.`created_at` as `created_at`",
"`user_id`",
"`vote_count`",
"`answer_count`",
@ -42,7 +42,7 @@ var (
"`question_id`",
"`question`.`title` as `title`",
"`answer`.`parsed_text` as `parsed_text`",
"`answer`.`created_at`",
"`answer`.`created_at` as `created_at`",
"`answer`.`user_id` as `user_id`",
"`answer`.`vote_count` as `vote_count`",
"0 as `answer_count`",

View File

@ -60,7 +60,7 @@ func (tr *tagCommonRepo) GetTagListByName(ctx context.Context, name string, hasR
cond := &entity.Tag{}
session := tr.data.DB.Where("")
if name != "" {
session.Where("slug_name LIKE ?", name+"%")
session.Where("slug_name LIKE ? or display_name LIKE ?", name+"%", name+"%")
} else {
session.UseBool("recommend")
cond.Recommend = true

View File

@ -127,8 +127,7 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
//question
r.GET("/question/info", a.questionController.GetQuestion)
r.POST("/question/search", a.questionController.SearchList)
r.GET("/question/page", a.questionController.Index)
r.GET("/question/page", a.questionController.QuestionPage)
r.GET("/question/similar/tag", a.questionController.SimilarQuestion)
r.GET("/personal/qa/top", a.questionController.UserTop)
r.GET("/personal/question/page", a.questionController.UserList)
@ -146,7 +145,6 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
r.GET("/tags/following", a.tagController.GetFollowingTags)
r.GET("/tag", a.tagController.GetTagInfo)
r.GET("/tag/synonyms", a.tagController.GetTagSynonyms)
r.GET("/question/index", a.questionController.Index)
//search
r.GET("/search", a.searchController.Search)
@ -222,6 +220,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
// upload file
r.POST("/file", a.uploadController.UploadFile)
r.POST("/post/render", a.uploadController.PostRender)
// activity
r.GET("/activity/timeline", a.activityController.GetObjectTimeline)

View File

@ -20,10 +20,10 @@ const (
)
type AnswerAddReq struct {
QuestionID string `json:"question_id" ` // question_id
Content string `json:"content" ` // content
HTML string `json:"html" ` // html
UserID string `json:"-" ` // user_id
QuestionID string `json:"question_id" ` // question_id
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content" ` // content
HTML string `json:"html" ` // html
UserID string `json:"-" ` // user_id
}
func (req *AnswerAddReq) Check() (errFields []*validator.FormErrorField, err error) {
@ -32,13 +32,13 @@ func (req *AnswerAddReq) Check() (errFields []*validator.FormErrorField, err err
}
type AnswerUpdateReq struct {
ID string `json:"id"` // id
QuestionID string `json:"question_id" ` // question_id
UserID string `json:"-" ` // user_id
Title string `json:"title" ` // title
Content string `json:"content"` // content
HTML string `json:"html" ` // html
EditSummary string `validate:"omitempty" json:"edit_summary"` // edit_summary
ID string `json:"id"` // id
QuestionID string `json:"question_id" ` // question_id
UserID string `json:"-" ` // user_id
Title string `json:"title" ` // title
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"` // content
HTML string `json:"html" ` // html
EditSummary string `validate:"omitempty" json:"edit_summary"` // edit_summary
NoNeedReview bool `json:"-"`
// whether user can edit it
CanEdit bool `json:"-"`

View File

@ -51,7 +51,8 @@ type UpdateCommentReq struct {
// parsed comment content
ParsedText string `validate:"omitempty" json:"parsed_text"`
// user id
UserID string `json:"-"`
UserID string `json:"-"`
IsAdmin bool `json:"-"`
}
func (req *UpdateCommentReq) Check() (errFields []*validator.FormErrorField, err error) {

View File

@ -1,6 +1,8 @@
package schema
import (
"time"
"github.com/answerdev/answer/internal/base/validator"
"github.com/answerdev/answer/pkg/converter"
)
@ -217,20 +219,72 @@ type UserQuestionInfo struct {
ViewCount int `json:"view_count"`
AnswerCount int `json:"answer_count"`
CollectionCount int `json:"collection_count"`
CreateTime int `json:"create_time"`
CreatedAt int64 `json:"created_at"`
AcceptedAnswerID string `json:"accepted_answer_id"`
Status string `json:"status"`
}
type QuestionSearch struct {
Page int `json:"page" form:"page"` // Query number of pages
PageSize int `json:"page_size" form:"page_size"` // Search page size
Order string `json:"order" form:"order"` // Search order by
// Tags []string `json:"tags" form:"tags"` // Search tag
Tag string `json:"tag" form:"tag"` //Search tag
TagIDs []string `json:"-" form:"-"` // Search tag
UserName string `json:"username" form:"username"` // Search username
UserID string `json:"-" form:"-"`
const (
QuestionOrderCondNewest = "newest"
QuestionOrderCondActive = "active"
QuestionOrderCondFrequent = "frequent"
QuestionOrderCondScore = "score"
QuestionOrderCondUnanswered = "unanswered"
)
// QuestionPageReq query questions page
type QuestionPageReq struct {
Page int `validate:"omitempty,min=1" form:"page"`
PageSize int `validate:"omitempty,min=1" form:"page_size"`
OrderCond string `validate:"omitempty,oneof=newest active frequent score unanswered" form:"order"`
Tag string `validate:"omitempty,gt=0,lte=100" form:"tag"`
Username string `validate:"omitempty,gt=0,lte=100" form:"username"`
LoginUserID string `json:"-"`
UserIDBeSearched string `json:"-"`
TagID string `json:"-"`
}
const (
QuestionPageRespOperationTypeAsked = "asked"
QuestionPageRespOperationTypeAnswered = "answered"
QuestionPageRespOperationTypeModified = "modified"
)
type QuestionPageResp struct {
ID string `json:"id" `
CreatedAt int64 `json:"created_at"`
Title string `json:"title"`
UrlTitle string `json:"url_title"`
Description string `json:"description"`
Status int `json:"status"`
Tags []*TagResp `json:"tags"`
// question statistical information
ViewCount int `json:"view_count"`
UniqueViewCount int `json:"unique_view_count"`
VoteCount int `json:"vote_count"`
AnswerCount int `json:"answer_count"`
CollectionCount int `json:"collection_count"`
FollowCount int `json:"follow_count"`
// answer information
AcceptedAnswerID string `json:"accepted_answer_id"`
LastAnswerID string `json:"last_answer_id"`
LastAnsweredUserID string `json:"-"`
LastAnsweredAt time.Time `json:"-"`
// operator information
OperatedAt int64 `json:"operated_at"`
Operator *QuestionPageRespOperator `json:"operator"`
OperationType string `json:"operation_type"`
}
type QuestionPageRespOperator struct {
ID string `json:"id"`
Username string `json:"username"`
Rank int `json:"rank"`
DisplayName string `json:"display_name"`
}
type AdminQuestionSearch struct {

View File

@ -0,0 +1,6 @@
package schema
// PostRenderReq post render request
type PostRenderReq struct {
Content string `json:"content"`
}

View File

@ -46,9 +46,9 @@ type SiteInterfaceReq struct {
// SiteBrandingReq site branding request
type SiteBrandingReq struct {
Logo string `validate:"required,gt=0,lte=512" form:"logo" json:"logo"`
Logo string `validate:"omitempty,gt=0,lte=512" form:"logo" json:"logo"`
MobileLogo string `validate:"omitempty,gt=0,lte=512" form:"mobile_logo" json:"mobile_logo"`
SquareIcon string `validate:"required,gt=0,lte=512" form:"square_icon" json:"square_icon"`
SquareIcon string `validate:"omitempty,gt=0,lte=512" form:"square_icon" json:"square_icon"`
Favicon string `validate:"omitempty,gt=0,lte=512" form:"favicon" json:"favicon"`
}
@ -134,7 +134,7 @@ type SiteThemeResp struct {
func (s *SiteThemeResp) TrTheme(ctx context.Context) {
la := handler.GetLangByCtx(ctx)
for _, option := range s.ThemeOptions {
tr := translator.GlobalTrans.Tr(la, option.Value)
tr := translator.Tr(la, option.Value)
// if tr is equal the option value means not found translation, so use the original label
if tr != option.Value {
option.Label = tr

View File

@ -2,12 +2,12 @@ package schema
import (
"encoding/json"
"regexp"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/validator"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/pkg/checker"
"github.com/answerdev/answer/pkg/converter"
"github.com/jinzhu/copier"
"github.com/segmentfault/pacman/errors"
)
@ -229,7 +229,7 @@ type UserEmailLogin struct {
// UserRegisterReq user register request
type UserRegisterReq struct {
// name
Name string `validate:"required,gt=4,lte=30" json:"name"`
Name string `validate:"required,gt=3,lte=30" json:"name"`
// email
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" `
// password
@ -278,13 +278,13 @@ type UpdateInfoRequest struct {
// display_name
DisplayName string `validate:"required,gt=0,lte=30" json:"display_name"`
// username
Username string `validate:"omitempty,gt=0,lte=30" json:"username"`
Username string `validate:"omitempty,gt=3,lte=30" json:"username"`
// avatar
Avatar AvatarInfo `json:"avatar"`
// bio
Bio string `validate:"omitempty,gt=0,lte=4096" json:"bio"`
// bio
BioHTML string `validate:"omitempty,gt=0,lte=4096" json:"bio_html"`
BioHTML string `json:"-"`
// website
Website string `validate:"omitempty,gt=0,lte=500" json:"website"`
// location
@ -299,19 +299,18 @@ type AvatarInfo struct {
Custom string `validate:"omitempty,gt=0,lte=200" json:"custom"`
}
func (u *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, err error) {
if len(u.Username) > 0 {
re := regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
match := re.MatchString(u.Username)
if !match {
func (req *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, err error) {
if len(req.Username) > 0 {
if checker.IsInvalidUsername(req.Username) {
errField := &validator.FormErrorField{
ErrorField: "username",
ErrorMsg: err.Error(),
ErrorMsg: reason.UsernameInvalid,
}
errFields = append(errFields, errField)
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
}
req.BioHTML = converter.Markdown2HTML(req.Bio)
return nil, nil
}

View File

@ -121,33 +121,14 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment
return nil, err
}
if objInfo.ObjectType == constant.QuestionObjectType {
cs.notificationQuestionComment(ctx, objInfo.ObjectCreatorUserID,
objInfo.QuestionID, objInfo.Title, comment.ID, req.UserID, comment.OriginalText)
} else if objInfo.ObjectType == constant.AnswerObjectType {
cs.notificationAnswerComment(ctx, objInfo.QuestionID, objInfo.Title, objInfo.AnswerID,
objInfo.ObjectCreatorUserID, comment.ID, req.UserID, comment.OriginalText)
}
if len(req.MentionUsernameList) > 0 {
cs.notificationMention(ctx, req.MentionUsernameList, comment.ID, req.UserID)
}
resp = &schema.GetCommentResp{}
resp.SetFromComment(comment)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID, req.CanEdit, req.CanDelete)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID,
time.Now(), req.CanEdit, req.CanDelete)
// get reply user info
if len(resp.ReplyUserID) > 0 {
replyUser, exist, err := cs.userCommon.GetUserBasicInfoByID(ctx, resp.ReplyUserID)
if err != nil {
return nil, err
}
if exist {
resp.ReplyUsername = replyUser.Username
resp.ReplyUserDisplayName = replyUser.DisplayName
resp.ReplyUserStatus = replyUser.Status
}
cs.notificationCommentReply(ctx, replyUser.ID, objInfo.QuestionID, req.UserID)
commentResp, err := cs.addCommentNotification(ctx, req, resp, comment, objInfo)
if err != nil {
return commentResp, err
}
// get user info
@ -178,6 +159,50 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment
return resp, nil
}
func (cs *CommentService) addCommentNotification(
ctx context.Context, req *schema.AddCommentReq, resp *schema.GetCommentResp,
comment *entity.Comment, objInfo *schema.SimpleObjectInfo) (*schema.GetCommentResp, error) {
// The priority of the notification
// 1. reply to user
// 2. comment mention to user
// 3. answer or question was commented
alreadyNotifiedUserID := make(map[string]bool)
// get reply user info
if len(resp.ReplyUserID) > 0 && resp.ReplyUserID != req.UserID {
replyUser, exist, err := cs.userCommon.GetUserBasicInfoByID(ctx, resp.ReplyUserID)
if err != nil {
return nil, err
}
if exist {
resp.ReplyUsername = replyUser.Username
resp.ReplyUserDisplayName = replyUser.DisplayName
resp.ReplyUserStatus = replyUser.Status
}
cs.notificationCommentReply(ctx, replyUser.ID, comment.ID, req.UserID)
alreadyNotifiedUserID[replyUser.ID] = true
return nil, nil
}
if len(req.MentionUsernameList) > 0 {
alreadyNotifiedUserIDs := cs.notificationMention(
ctx, req.MentionUsernameList, comment.ID, req.UserID, alreadyNotifiedUserID)
for _, userID := range alreadyNotifiedUserIDs {
alreadyNotifiedUserID[userID] = true
}
return nil, nil
}
if objInfo.ObjectType == constant.QuestionObjectType && !alreadyNotifiedUserID[objInfo.ObjectCreatorUserID] {
cs.notificationQuestionComment(ctx, objInfo.ObjectCreatorUserID,
objInfo.QuestionID, objInfo.Title, comment.ID, req.UserID, comment.OriginalText)
} else if objInfo.ObjectType == constant.AnswerObjectType && !alreadyNotifiedUserID[objInfo.ObjectCreatorUserID] {
cs.notificationAnswerComment(ctx, objInfo.QuestionID, objInfo.Title, objInfo.AnswerID,
objInfo.ObjectCreatorUserID, comment.ID, req.UserID, comment.OriginalText)
}
return nil, nil
}
// RemoveComment delete comment
func (cs *CommentService) RemoveComment(ctx context.Context, req *schema.RemoveCommentReq) (err error) {
return cs.commentRepo.RemoveComment(ctx, req.CommentID)
@ -185,6 +210,19 @@ func (cs *CommentService) RemoveComment(ctx context.Context, req *schema.RemoveC
// UpdateComment update comment
func (cs *CommentService) UpdateComment(ctx context.Context, req *schema.UpdateCommentReq) (err error) {
old, exist, err := cs.commentCommonRepo.GetComment(ctx, req.CommentID)
if err != nil {
return
}
if !exist {
return errors.BadRequest(reason.CommentNotFound)
}
// user can edit the comment that was posted by himself before deadline.
if !req.IsAdmin && (time.Now().After(old.CreatedAt.Add(constant.CommentEditDeadline))) {
return errors.BadRequest(reason.CommentCannotEditAfterDeadline)
}
comment := &entity.Comment{}
_ = copier.Copy(comment, req)
comment.ID = req.CommentID
@ -198,7 +236,7 @@ func (cs *CommentService) GetComment(ctx context.Context, req *schema.GetComment
return
}
if !exist {
return nil, errors.BadRequest(reason.UnknownError)
return nil, errors.BadRequest(reason.CommentNotFound)
}
resp = &schema.GetCommentResp{
@ -243,7 +281,8 @@ func (cs *CommentService) GetComment(ctx context.Context, req *schema.GetComment
// check if current user vote this comment
resp.IsVote = cs.checkIsVote(ctx, req.UserID, resp.CommentID)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID, req.CanEdit, req.CanDelete)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID,
comment.CreatedAt, req.CanEdit, req.CanDelete)
return resp, nil
}
@ -339,7 +378,7 @@ func (cs *CommentService) convertCommentEntity2Resp(ctx context.Context, req *sc
commentResp.IsVote = cs.checkIsVote(ctx, req.UserID, commentResp.CommentID)
commentResp.MemberActions = permission.GetCommentPermission(ctx,
req.UserID, commentResp.UserID, req.CanEdit, req.CanDelete)
req.UserID, commentResp.UserID, comment.CreatedAt, req.CanEdit, req.CanDelete)
return commentResp, nil
}
@ -521,14 +560,16 @@ func (cs *CommentService) notificationCommentReply(ctx context.Context, replyUse
notice_queue.AddNotification(msg)
}
func (cs *CommentService) notificationMention(ctx context.Context, mentionUsernameList []string, commentID, commentUserID string) {
func (cs *CommentService) notificationMention(
ctx context.Context, mentionUsernameList []string, commentID, commentUserID string,
alreadyNotifiedUserID map[string]bool) (alreadyNotifiedUserIDs []string) {
for _, username := range mentionUsernameList {
userInfo, exist, err := cs.userCommon.GetUserBasicInfoByUserName(ctx, username)
if err != nil {
log.Error(err)
continue
}
if exist {
if exist && !alreadyNotifiedUserID[userInfo.ID] {
msg := &schema.NotificationMsg{
ReceiverUserID: userInfo.ID,
TriggerUserID: commentUserID,
@ -538,6 +579,8 @@ func (cs *CommentService) notificationMention(ctx context.Context, mentionUserna
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.MentionYou
notice_queue.AddNotification(msg)
alreadyNotifiedUserIDs = append(alreadyNotifiedUserIDs, userInfo.ID)
}
}
return alreadyNotifiedUserIDs
}

View File

@ -134,7 +134,7 @@ func (ns *NotificationService) GetNotificationPage(ctx context.Context, searchCo
continue
}
lang, _ := ctx.Value(constant.AcceptLanguageFlag).(i18n.Language)
item.NotificationAction = translator.GlobalTrans.Tr(lang, item.NotificationAction)
item.NotificationAction = translator.Tr(lang, item.NotificationAction)
item.ID = notificationInfo.ID
item.UpdateTime = notificationInfo.UpdatedAt.Unix()
if notificationInfo.IsRead == schema.NotificationRead {

View File

@ -73,7 +73,7 @@ func (ns *NotificationCommon) HandleNotification() {
// AddNotification
// need set
// UserID
// LoginUserID
// Type 1 inbox 2 achievement
// [inbox] Activity
// [achievement] Rank

View File

@ -2,13 +2,15 @@ package permission
import (
"context"
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/schema"
)
// GetCommentPermission get comment permission
func GetCommentPermission(ctx context.Context, userID string, creatorUserID string, canEdit, canDelete bool) (
actions []*schema.PermissionMemberAction) {
func GetCommentPermission(ctx context.Context, userID string, creatorUserID string,
createdAt time.Time, canEdit, canDelete bool) (actions []*schema.PermissionMemberAction) {
actions = make([]*schema.PermissionMemberAction, 0)
if len(userID) > 0 {
actions = append(actions, &schema.PermissionMemberAction{
@ -17,7 +19,8 @@ func GetCommentPermission(ctx context.Context, userID string, creatorUserID stri
Type: "reason",
})
}
if canEdit || userID == creatorUserID {
deadline := createdAt.Add(constant.CommentEditDeadline)
if canEdit || (userID == creatorUserID && time.Now().Before(deadline)) {
actions = append(actions, &schema.PermissionMemberAction{
Action: "edit",
Name: "Edit",

View File

@ -11,6 +11,7 @@ import (
"github.com/answerdev/answer/internal/service/activity_queue"
"github.com/answerdev/answer/internal/service/config"
"github.com/answerdev/answer/internal/service/meta"
"github.com/answerdev/answer/pkg/checker"
"github.com/answerdev/answer/pkg/htmltext"
"github.com/segmentfault/pacman/errors"
@ -30,8 +31,8 @@ type QuestionRepo interface {
UpdateQuestion(ctx context.Context, question *entity.Question, Cols []string) (err error)
GetQuestion(ctx context.Context, id string) (question *entity.Question, exist bool, err error)
GetQuestionList(ctx context.Context, question *entity.Question) (questions []*entity.Question, err error)
GetQuestionPage(ctx context.Context, page, pageSize int, question *entity.Question) (questions []*entity.Question, total int64, err error)
SearchList(ctx context.Context, search *schema.QuestionSearch) ([]*entity.QuestionTag, int64, error)
GetQuestionPage(ctx context.Context, page, pageSize int, userID, tagID, orderCond string) (
questionList []*entity.Question, total int64, err error)
UpdateQuestionStatus(ctx context.Context, question *entity.Question) (err error)
SearchByTitleLike(ctx context.Context, title string) (questionList []*entity.Question, err error)
UpdatePvCount(ctx context.Context, questionID string) (err error)
@ -126,21 +127,15 @@ func (qs *QuestionCommon) UpdataPostSetTime(ctx context.Context, questionID stri
func (qs *QuestionCommon) FindInfoByID(ctx context.Context, questionIDs []string, loginUserID string) (map[string]*schema.QuestionInfo, error) {
list := make(map[string]*schema.QuestionInfo)
listAddTag := make([]*entity.QuestionTag, 0)
questionList, err := qs.questionRepo.FindByID(ctx, questionIDs)
if err != nil {
return list, err
}
for _, item := range questionList {
itemAddTag := &entity.QuestionTag{}
itemAddTag.Question = *item
listAddTag = append(listAddTag, itemAddTag)
}
QuestionInfo, err := qs.ListFormat(ctx, listAddTag, loginUserID)
questions, err := qs.FormatQuestions(ctx, questionList, loginUserID)
if err != nil {
return list, err
}
for _, item := range QuestionInfo {
for _, item := range questions {
list[item.ID] = item
}
return list, nil
@ -193,9 +188,15 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
showinfo.Tags = tagmap
userIds := make([]string, 0)
userIds = append(userIds, dbinfo.UserID)
userIds = append(userIds, dbinfo.LastEditUserID)
userIds = append(userIds, showinfo.LastAnsweredUserID)
if checker.IsNotZeroString(dbinfo.UserID) {
userIds = append(userIds, dbinfo.UserID)
}
if checker.IsNotZeroString(dbinfo.LastEditUserID) {
userIds = append(userIds, dbinfo.LastEditUserID)
}
if checker.IsNotZeroString(showinfo.LastAnsweredUserID) {
userIds = append(userIds, showinfo.LastAnsweredUserID)
}
userInfoMap, err := qs.userCommon.BatchUserBasicInfoByID(ctx, userIds)
if err != nil {
return showinfo, err
@ -244,13 +245,109 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
return showinfo, nil
}
func (qs *QuestionCommon) ListFormat(ctx context.Context, questionList []*entity.QuestionTag, loginUserID string) ([]*schema.QuestionInfo, error) {
func (qs *QuestionCommon) FormatQuestionsPage(
ctx context.Context, questionList []*entity.Question, loginUserID string, orderCond string) (
formattedQuestions []*schema.QuestionPageResp, err error) {
formattedQuestions = make([]*schema.QuestionPageResp, 0)
questionIDs := make([]string, 0)
userIDs := make([]string, 0)
for _, questionInfo := range questionList {
t := &schema.QuestionPageResp{
ID: questionInfo.ID,
CreatedAt: questionInfo.CreatedAt.Unix(),
Title: questionInfo.Title,
UrlTitle: htmltext.UrlTitle(questionInfo.Title),
Description: htmltext.FetchExcerpt(questionInfo.ParsedText, "...", 240),
Status: questionInfo.Status,
ViewCount: questionInfo.ViewCount,
UniqueViewCount: questionInfo.UniqueViewCount,
VoteCount: questionInfo.VoteCount,
AnswerCount: questionInfo.AnswerCount,
CollectionCount: questionInfo.CollectionCount,
FollowCount: questionInfo.FollowCount,
AcceptedAnswerID: questionInfo.AcceptedAnswerID,
LastAnswerID: questionInfo.LastAnswerID,
}
questionIDs = append(questionIDs, questionInfo.ID)
userIDs = append(userIDs, questionInfo.UserID)
haveEdited, haveAnswered := false, false
if checker.IsNotZeroString(questionInfo.LastEditUserID) {
haveEdited = true
userIDs = append(userIDs, questionInfo.LastEditUserID)
}
if checker.IsNotZeroString(questionInfo.LastAnswerID) {
haveAnswered = true
answerInfo, exist, err := qs.answerRepo.GetAnswer(ctx, questionInfo.LastAnswerID)
if err == nil && exist {
if answerInfo.LastEditUserID != "0" {
t.LastAnsweredUserID = answerInfo.LastEditUserID
} else {
t.LastAnsweredUserID = answerInfo.UserID
}
t.LastAnsweredAt = answerInfo.CreatedAt
userIDs = append(userIDs, t.LastAnsweredUserID)
}
}
// if order condition is newest or nobody edited or nobody answered, only show question author
if orderCond == schema.QuestionOrderCondNewest || (!haveEdited && !haveAnswered) {
t.OperationType = schema.QuestionPageRespOperationTypeAsked
t.OperatedAt = questionInfo.CreatedAt.Unix()
t.Operator = &schema.QuestionPageRespOperator{ID: questionInfo.UserID}
} else {
// if no one
if haveEdited {
t.OperationType = schema.QuestionPageRespOperationTypeModified
t.OperatedAt = questionInfo.UpdatedAt.Unix()
t.Operator = &schema.QuestionPageRespOperator{ID: questionInfo.LastEditUserID}
}
if haveAnswered {
if t.LastAnsweredAt.Unix() > t.OperatedAt {
t.OperationType = schema.QuestionPageRespOperationTypeAnswered
t.OperatedAt = t.LastAnsweredAt.Unix()
t.Operator = &schema.QuestionPageRespOperator{ID: t.LastAnsweredUserID}
}
}
}
formattedQuestions = append(formattedQuestions, t)
}
tagsMap, err := qs.tagCommon.BatchGetObjectTag(ctx, questionIDs)
if err != nil {
return formattedQuestions, err
}
userInfoMap, err := qs.userCommon.BatchUserBasicInfoByID(ctx, userIDs)
if err != nil {
return formattedQuestions, err
}
for _, item := range formattedQuestions {
tags, ok := tagsMap[item.ID]
if ok {
item.Tags = tags
} else {
item.Tags = make([]*schema.TagResp, 0)
}
userInfo := userInfoMap[item.Operator.ID]
if userInfo != nil {
item.Operator.DisplayName = userInfo.DisplayName
item.Operator.Username = userInfo.Username
item.Operator.Rank = userInfo.Rank
}
}
return formattedQuestions, nil
}
func (qs *QuestionCommon) FormatQuestions(ctx context.Context, questionList []*entity.Question, loginUserID string) ([]*schema.QuestionInfo, error) {
list := make([]*schema.QuestionInfo, 0)
objectIds := make([]string, 0)
userIds := make([]string, 0)
for _, questionInfo := range questionList {
item := qs.ShowListFormat(ctx, questionInfo)
item := qs.ShowFormat(ctx, questionInfo)
list = append(list, item)
objectIds = append(objectIds, item.ID)
userIds = append(userIds, item.UserID)
@ -387,8 +484,8 @@ func (as *QuestionCommon) RemoveAnswer(ctx context.Context, id string) (err erro
return as.answerRepo.RemoveAnswer(ctx, id)
}
func (qs *QuestionCommon) ShowListFormat(ctx context.Context, data *entity.QuestionTag) *schema.QuestionInfo {
return qs.ShowFormat(ctx, &data.Question)
func (qs *QuestionCommon) ShowListFormat(ctx context.Context, data *entity.Question) *schema.QuestionInfo {
return qs.ShowFormat(ctx, data)
}
func (qs *QuestionCommon) ShowFormat(ctx context.Context, data *entity.Question) *schema.QuestionInfo {

View File

@ -140,8 +140,8 @@ func (qs *QuestionService) CloseMsgList(ctx context.Context, lang i18n.Language)
return nil, errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
}
for _, t := range resp {
t.Name = translator.GlobalTrans.Tr(lang, t.Name)
t.Description = translator.GlobalTrans.Tr(lang, t.Description)
t.Name = translator.Tr(lang, t.Name)
t.Description = translator.Tr(lang, t.Description)
}
return resp, err
}
@ -163,7 +163,7 @@ func (qs *QuestionService) CheckAddQuestion(ctx context.Context, req *schema.Que
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err
@ -176,7 +176,7 @@ func (qs *QuestionService) CheckAddQuestion(ctx context.Context, req *schema.Que
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err
@ -213,7 +213,7 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.TagNotFound),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err
@ -226,7 +226,7 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err
@ -476,11 +476,7 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest
question.UpdatedAt = now
question.PostUpdateTime = now
question.UserID = dbinfo.UserID
question.LastEditUserID = "0"
if dbinfo.UserID != req.UserID {
question.LastEditUserID = req.UserID
}
question.LastEditUserID = req.UserID
oldTags, tagerr := qs.tagCommon.GetObjectEntityTag(ctx, question.ID)
if tagerr != nil {
@ -543,7 +539,7 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest
errorlist := make([]*validator.FormErrorField, 0)
errorlist = append(errorlist, &validator.FormErrorField{
ErrorField: "tags",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.RecommendTagEnter),
})
err = errors.BadRequest(reason.RecommendTagEnter)
return errorlist, err
@ -655,12 +651,13 @@ func (qs *QuestionService) SearchUserList(ctx context.Context, userName, order s
if !Exist {
return userlist, 0, nil
}
search := &schema.QuestionSearch{}
search.Order = order
search := &schema.QuestionPageReq{}
search.OrderCond = order
search.Page = page
search.PageSize = pageSize
search.UserID = userinfo.ID
questionlist, count, err := qs.SearchList(ctx, search, loginUserID)
search.UserIDBeSearched = userinfo.ID
search.LoginUserID = loginUserID
questionlist, count, err := qs.GetQuestionPage(ctx, search)
if err != nil {
return userlist, 0, err
}
@ -778,12 +775,13 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
if !Exist {
return userQuestionlist, userAnswerlist, nil
}
search := &schema.QuestionSearch{}
search.Order = "score"
search := &schema.QuestionPageReq{}
search.OrderCond = "score"
search.Page = 0
search.PageSize = 5
search.UserID = userinfo.ID
questionlist, _, err := qs.SearchList(ctx, search, loginUserID)
search.UserIDBeSearched = userinfo.ID
search.LoginUserID = loginUserID
questionlist, _, err := qs.GetQuestionPage(ctx, search)
if err != nil {
return userQuestionlist, userAnswerlist, err
}
@ -858,57 +856,64 @@ func (qs *QuestionService) SearchByTitleLike(ctx context.Context, title string,
}
// SimilarQuestion
func (qs *QuestionService) SimilarQuestion(ctx context.Context, questionID string, loginUserID string) ([]*schema.QuestionInfo, int64, error) {
list := make([]*schema.QuestionInfo, 0)
func (qs *QuestionService) SimilarQuestion(ctx context.Context, questionID string, loginUserID string) ([]*schema.QuestionPageResp, int64, error) {
question, err := qs.questioncommon.Info(ctx, questionID, loginUserID)
if err != nil {
return list, 0, nil
return nil, 0, nil
}
tagNames := make([]string, 0, len(question.Tags))
for _, tag := range question.Tags {
tagNames = append(tagNames, tag.SlugName)
}
search := &schema.QuestionSearch{}
search.Order = "frequent"
search := &schema.QuestionPageReq{}
search.OrderCond = "frequent"
search.Page = 0
search.PageSize = 6
if len(tagNames) > 0 {
search.Tag = tagNames[0]
}
return qs.SearchList(ctx, search, loginUserID)
search.LoginUserID = loginUserID
return qs.GetQuestionPage(ctx, search)
}
// SearchList
func (qs *QuestionService) SearchList(ctx context.Context, req *schema.QuestionSearch, loginUserID string) ([]*schema.QuestionInfo, int64, error) {
// GetQuestionPage query questions page
func (qs *QuestionService) GetQuestionPage(ctx context.Context, req *schema.QuestionPageReq) (
questions []*schema.QuestionPageResp, total int64, err error) {
questions = make([]*schema.QuestionPageResp, 0)
// query by tag condition
if len(req.Tag) > 0 {
tagInfo, has, err := qs.tagCommon.GetTagBySlugName(ctx, strings.ToLower(req.Tag))
tagInfo, exist, err := qs.tagCommon.GetTagBySlugName(ctx, strings.ToLower(req.Tag))
if err != nil {
log.Error("tagCommon.GetTagListByNames error", err)
return nil, 0, err
}
if has {
req.TagIDs = append(req.TagIDs, tagInfo.ID)
if exist {
req.TagID = tagInfo.ID
}
}
list := make([]*schema.QuestionInfo, 0)
if req.UserName != "" {
userinfo, exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, req.UserName)
// query by user condition
if req.Username != "" {
userinfo, exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, req.Username)
if err != nil {
return list, 0, err
return nil, 0, err
}
if !exist {
return list, 0, err
return questions, 0, nil
}
req.UserID = userinfo.ID
req.UserIDBeSearched = userinfo.ID
}
questionList, count, err := qs.questionRepo.SearchList(ctx, req)
questionList, total, err := qs.questionRepo.GetQuestionPage(ctx, req.Page, req.PageSize,
req.UserIDBeSearched, req.TagID, req.OrderCond)
if err != nil {
return list, count, err
return nil, 0, err
}
list, err = qs.questioncommon.ListFormat(ctx, questionList, loginUserID)
questions, err = qs.questioncommon.FormatQuestionsPage(ctx, questionList, req.LoginUserID, req.OrderCond)
if err != nil {
return list, count, err
return nil, 0, err
}
return list, count, nil
return questions, total, nil
}
func (qs *QuestionService) AdminSetQuestionStatus(ctx context.Context, questionID string, setStatusStr string) error {

View File

@ -74,8 +74,8 @@ func (rs *ReportService) GetReportTypeList(ctx context.Context, lang i18n.Langua
err = errors.BadRequest(reason.UnknownError)
}
for _, t := range resp {
t.Name = translator.GlobalTrans.Tr(lang, t.Name)
t.Description = translator.GlobalTrans.Tr(lang, t.Description)
t.Name = translator.Tr(lang, t.Name)
t.Description = translator.Tr(lang, t.Description)
}
return resp, err
}

View File

@ -72,13 +72,13 @@ func (rs *RoleService) GetRoleMapping(ctx context.Context) (roleMapping map[int]
func (rs *RoleService) translateRole(ctx context.Context, role *entity.Role) {
switch role.Name {
case roleUserName:
role.Name = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleNameUser)
role.Description = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionUser)
role.Name = translator.Tr(handler.GetLangByCtx(ctx), trRoleNameUser)
role.Description = translator.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionUser)
case roleAdminName:
role.Name = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleNameAdmin)
role.Description = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionAdmin)
role.Name = translator.Tr(handler.GetLangByCtx(ctx), trRoleNameAdmin)
role.Description = translator.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionAdmin)
case roleModeratorName:
role.Name = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleNameModerator)
role.Description = translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionModerator)
role.Name = translator.Tr(handler.GetLangByCtx(ctx), trRoleNameModerator)
role.Description = translator.Tr(handler.GetLangByCtx(ctx), trRoleDescriptionModerator)
}
}

View File

@ -496,7 +496,7 @@ func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *
thisObjTagNameList := make([]string, 0)
thisObjTagIDList := make([]string, 0)
for _, t := range objectTagData.Tags {
t.SlugName = strings.ToLower(t.SlugName)
// t.SlugName = strings.ToLower(t.SlugName)
thisObjTagNameList = append(thisObjTagNameList, t.SlugName)
}
@ -508,13 +508,13 @@ func (ts *TagCommonService) ObjectChangeTag(ctx context.Context, objectTagData *
tagInDbMapping := make(map[string]*entity.Tag)
for _, tag := range tagListInDb {
tagInDbMapping[tag.SlugName] = tag
tagInDbMapping[strings.ToLower(tag.SlugName)] = tag
thisObjTagIDList = append(thisObjTagIDList, tag.ID)
}
addTagList := make([]*entity.Tag, 0)
for _, tag := range objectTagData.Tags {
_, ok := tagInDbMapping[tag.SlugName]
_, ok := tagInDbMapping[strings.ToLower(tag.SlugName)]
if ok {
continue
}

View File

@ -12,6 +12,7 @@ import (
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema"
"github.com/answerdev/answer/internal/service/activity"
"github.com/answerdev/answer/internal/service/auth"
"github.com/answerdev/answer/internal/service/role"
usercommon "github.com/answerdev/answer/internal/service/user_common"
@ -38,6 +39,7 @@ type UserAdminService struct {
userRoleRelService *role.UserRoleRelService
authService *auth.AuthService
userCommonService *usercommon.UserCommon
userActivity activity.UserActiveActivityRepo
}
// NewUserAdminService new user admin service
@ -46,12 +48,14 @@ func NewUserAdminService(
userRoleRelService *role.UserRoleRelService,
authService *auth.AuthService,
userCommonService *usercommon.UserCommon,
userActivity activity.UserActiveActivityRepo,
) *UserAdminService {
return &UserAdminService{
userRepo: userRepo,
userRoleRelService: userRoleRelService,
authService: authService,
userCommonService: userCommonService,
userActivity: userActivity,
}
}
@ -83,7 +87,17 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up
userInfo.Status = entity.UserStatusAvailable
userInfo.MailStatus = entity.EmailStatusAvailable
}
return us.userRepo.UpdateUserStatus(ctx, userInfo.ID, userInfo.Status, userInfo.MailStatus, userInfo.EMail)
err = us.userRepo.UpdateUserStatus(ctx, userInfo.ID, userInfo.Status, userInfo.MailStatus, userInfo.EMail)
if err != nil {
return err
}
// if user reputation is zero means this user is inactive, so try to activate this user.
if req.IsNormal() && userInfo.Rank == 0 {
return us.userActivity.UserActive(ctx, userInfo.ID)
}
return nil
}
// UpdateUserRole update user role

View File

@ -2,7 +2,6 @@ package usercommon
import (
"context"
"regexp"
"strings"
"github.com/Chain-Zhang/pinyin"
@ -130,9 +129,7 @@ func (us *UserCommon) MakeUsername(ctx context.Context, displayName string) (use
username = strings.ToLower(username)
suffix := ""
re := regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
match := re.MatchString(username)
if !match {
if checker.IsInvalidUsername(username) {
return "", errors.BadRequest(reason.UsernameInvalid)
}

View File

@ -21,6 +21,7 @@ import (
"github.com/answerdev/answer/internal/service/siteinfo_common"
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/answerdev/answer/internal/service/user_external_login"
"github.com/answerdev/answer/pkg/checker"
"github.com/google/uuid"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
@ -246,20 +247,31 @@ func (us *UserService) UserModifyPassword(ctx context.Context, request *schema.U
}
// UpdateInfo update user info
func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoRequest) (err error) {
func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoRequest) (
errFields []*validator.FormErrorField, err error) {
if len(req.Username) > 0 {
userInfo, exist, err := us.userRepo.GetByUsername(ctx, req.Username)
if err != nil {
return err
return nil, err
}
if exist && userInfo.ID != req.UserID {
return errors.BadRequest(reason.UsernameDuplicate)
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "username",
ErrorMsg: reason.UsernameDuplicate,
})
return errFields, errors.BadRequest(reason.UsernameDuplicate)
}
if checker.IsReservedUsername(req.Username) {
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "username",
ErrorMsg: reason.UsernameInvalid,
})
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
}
avatar, err := json.Marshal(req.Avatar)
if err != nil {
err = errors.BadRequest(reason.UserSetAvatar).WithError(err).WithStack()
return err
return nil, errors.BadRequest(reason.UserSetAvatar).WithError(err).WithStack()
}
userInfo := entity.User{}
userInfo.ID = req.UserID
@ -270,10 +282,8 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq
userInfo.Location = req.Location
userInfo.Website = req.Website
userInfo.Username = req.Username
if err := us.userRepo.UpdateInfo(ctx, &userInfo); err != nil {
return err
}
return nil
err = us.userRepo.UpdateInfo(ctx, &userInfo)
return nil, err
}
func (us *UserService) UserEmailHas(ctx context.Context, email string) (bool, error) {
@ -505,7 +515,7 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
if exist {
resp = append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "e_mail",
ErrorMsg: translator.GlobalTrans.Tr(handler.GetLangByCtx(ctx), reason.EmailDuplicate),
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.EmailDuplicate),
})
return resp, errors.BadRequest(reason.EmailDuplicate)
}

View File

@ -2,15 +2,29 @@ package checker
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"github.com/answerdev/answer/configs"
"github.com/answerdev/answer/internal/cli"
"github.com/answerdev/answer/pkg/dir"
)
var (
reservedUsernameMapping = make(map[string]bool)
reservedUsernameInit sync.Once
)
func init() {
func initReservedUsername() {
reservedUsernamesJsonFilePath := filepath.Join(cli.ConfigFileDir, cli.DefaultReservedUsernamesConfigFileName)
if dir.CheckFileExist(reservedUsernamesJsonFilePath) {
// if reserved username file exists, read it and replace configuration
reservedUsernamesJsonFile, err := os.ReadFile(reservedUsernamesJsonFilePath)
if err == nil {
configs.ReservedUsernames = reservedUsernamesJsonFile
}
}
var usernames []string
_ = json.Unmarshal(configs.ReservedUsernames, &usernames)
for _, username := range usernames {
@ -20,5 +34,6 @@ func init() {
// IsReservedUsername checks whether the username is reserved
func IsReservedUsername(username string) bool {
reservedUsernameInit.Do(initReservedUsername)
return reservedUsernameMapping[username]
}

11
pkg/checker/username.go Normal file
View File

@ -0,0 +1,11 @@
package checker
import "regexp"
var (
usernameReg = regexp.MustCompile(`^[a-z0-9._-]{4,30}$`)
)
func IsInvalidUsername(username string) bool {
return !usernameReg.MatchString(username)
}

View File

@ -0,0 +1,6 @@
package checker
// IsNotZeroString check s is not empty string and is not "0"
func IsNotZeroString(s string) bool {
return len(s) > 0 && s != "0"
}

View File

@ -3,22 +3,28 @@ package converter
import (
"bytes"
"github.com/asaskevich/govalidator"
"github.com/microcosm-cc/bluemonday"
"github.com/segmentfault/pacman/log"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
goldmarkHTML "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// Markdown2HTML convert markdown to html
func Markdown2HTML(source string) string {
mdConverter := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithExtensions(&DangerousHTMLFilterExtension{}, extension.GFM),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
goldmarkHTML.WithHardWraps(),
),
)
var buf bytes.Buffer
@ -28,3 +34,84 @@ func Markdown2HTML(source string) string {
}
return buf.String()
}
type DangerousHTMLFilterExtension struct {
}
func (e *DangerousHTMLFilterExtension) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&DangerousHTMLRenderer{
Config: goldmarkHTML.NewConfig(),
Filter: bluemonday.UGCPolicy(),
}, 1),
))
}
type DangerousHTMLRenderer struct {
goldmarkHTML.Config
Filter *bluemonday.Policy
}
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
func (r *DangerousHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
reg.Register(ast.KindLink, r.renderLink)
}
func (r *DangerousHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkSkipChildren, nil
}
n := node.(*ast.RawHTML)
l := n.Segments.Len()
for i := 0; i < l; i++ {
segment := n.Segments.At(i)
_, _ = w.Write(r.Filter.SanitizeBytes(segment.Value(source)))
}
return ast.WalkSkipChildren, nil
}
func (r *DangerousHTMLRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.HTMLBlock)
if entering {
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
r.Writer.SecureWrite(w, r.Filter.SanitizeBytes(line.Value(source)))
}
} else {
if n.HasClosure() {
closure := n.ClosureLine
r.Writer.SecureWrite(w, closure.Value(source))
}
}
return ast.WalkContinue, nil
}
func (r *DangerousHTMLRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Link)
if entering && r.renderLinkIsUrl(string(n.Destination)) {
_, _ = w.WriteString("<a href=\"")
if r.Unsafe || !html.IsDangerousURL(n.Destination) {
_, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
}
_ = w.WriteByte('"')
if n.Title != nil {
_, _ = w.WriteString(` title="`)
r.Writer.Write(w, n.Title)
_ = w.WriteByte('"')
}
if n.Attributes() != nil {
html.RenderAttributes(w, n, html.LinkAttributeFilter)
}
_ = w.WriteByte('>')
} else {
_, _ = w.WriteString("</a>")
}
return ast.WalkContinue, nil
}
func (r *DangerousHTMLRenderer) renderLinkIsUrl(verifyUrl string) bool {
return govalidator.IsURL(verifyUrl)
}

View File

@ -75,11 +75,10 @@ func FetchExcerpt(html, trimMarker string, limit int) (text string) {
runeText := []rune(text)
if len(runeText) <= limit {
text = string(runeText)
} else {
text = string(runeText[0:limit])
return
}
text += trimMarker
text = string(runeText[0:limit]) + trimMarker
return
}

View File

@ -50,6 +50,10 @@ func TestFetchExcerpt(t *testing.T) {
expected = "hello你好😂..."
text = FetchExcerpt("<p>hello你好😂world</p>", "...", 8)
assert.Equal(t, expected, text)
expected = "hello你好"
text = FetchExcerpt("<p>hello你好</p>", "...", 8)
assert.Equal(t, expected, text)
}
func TestUrlTitle(t *testing.T) {

7
ui/.gitignore vendored
View File

@ -9,7 +9,12 @@
/coverage
# production
/build
/build/*/*/*
/build/*.json
/build/*.ico
/build/*.txt
# misc
.DS_Store

View File

@ -71,7 +71,7 @@ If you would like to help us with the i18n translation, please visit [Answer@Cro
├── common (project information/data defined here)
├── components (all components of the project)
├── hooks (all hooks of the project)
├── i18n (Initialize the front-end i18n)
├── i18n (Used only to initialize the front-end i18n tool)
├── pages (all pages of the project)
├── router (Project routing definition)
├── services (all data api of the project)

View File

@ -1 +1 @@
<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><script defer="defer" src="/static/js/main.2dcebe98.js"></script><link href="/static/css/main.401dc3ca.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"><div id="spin-mask"><noscript><style>#spin-mask{display:none!important}</style></noscript><style>@keyframes _doc-spin{to{transform:rotate(360deg)}}#spin-mask{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#fff;z-index:9999}#spin-container{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}#spin-container .spinner{box-sizing:border-box;display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25rem solid currentColor;border-right-color:transparent;color:rgba(108,117,125,.75);border-radius:50%;animation:.75s linear infinite _doc-spin}</style><div id="spin-container"><div class="spinner"></div></div></div></div></body></html>
<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><script defer="defer" src="/static/js/main.cb9bf782.js"></script><link href="/static/css/main.b8d8739f.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"><div id="spin-mask"><noscript><style>#spin-mask{display:none!important}</style></noscript><style>@keyframes _doc-spin{to{transform:rotate(360deg)}}#spin-mask{position:fixed;top:0;right:0;bottom:0;left:0;background-color:#fff;z-index:9999}#spin-container{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}#spin-container .spinner{box-sizing:border-box;display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25rem solid currentColor;border-right-color:transparent;color:rgba(108,117,125,.75);border-radius:50%;animation:.75s linear infinite _doc-spin}</style><div id="spin-container"><div class="spinner"></div></div></div></div></body></html>

View File

@ -0,0 +1 @@
# Build Static

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
$link-hover-decoration: none;
$enable-negative-margins: true;
$blue: #0033FF !default;
$placeholder-opacity-max: .2;
$placeholder-opacity-min: .1;

View File

@ -102,6 +102,7 @@ export interface ModifyUserReq {
}
export interface UserInfoBase {
id?: string;
avatar: any;
username: string;
display_name: string;

View File

@ -10,7 +10,7 @@ import { marked } from 'marked';
import * as Types from '@/common/interface';
import { Modal } from '@/components';
import { usePageUsers, useReportModal } from '@/hooks';
import { matchedUsers, parseUserInfo, scrollTop } from '@/utils';
import { matchedUsers, parseUserInfo, scrollTop, bgFadeOut } from '@/utils';
import { tryNormalLogged } from '@/utils/guard';
import {
useQueryComments,
@ -27,7 +27,6 @@ import './index.scss';
const Comment = ({ objectId, mode, commentId }) => {
const pageUsers = usePageUsers();
const [pageIndex, setPageIndex] = useState(0);
const [comments, setComments] = useState<any>([]);
const [visibleComment, setVisibleComment] = useState(false);
const pageSize = pageIndex === 0 ? 3 : 15;
const { data, mutate } = useQueryComments({
@ -36,6 +35,7 @@ const Comment = ({ objectId, mode, commentId }) => {
page: pageIndex,
page_size: pageSize,
});
const [comments, setComments] = useState<any>([]);
const reportModal = useReportModal();
@ -44,6 +44,7 @@ const Comment = ({ objectId, mode, commentId }) => {
if (pageIndex === 0 && co.comment_id === commentId) {
setTimeout(() => {
scrollTop(el);
bgFadeOut(el);
}, 100);
}
}, []);
@ -74,6 +75,9 @@ const Comment = ({ objectId, mode, commentId }) => {
}, [data]);
const handleReply = (id) => {
if (!tryNormalLogged(true)) {
return;
}
setComments(
comments.map((item) => {
if (item.comment_id === id) {
@ -99,6 +103,9 @@ const Comment = ({ objectId, mode, commentId }) => {
const users = matchedUsers(item.value);
const userNames = unionBy(users.map((user) => user.userName));
const html = marked.parse(parseUserInfo(item.value));
if (!item.value || !html) {
return;
}
const params = {
object_id: objectId,
original_text: item.value,
@ -163,9 +170,8 @@ const Comment = ({ objectId, mode, commentId }) => {
deleteComment(id).then(() => {
if (pageIndex === 0) {
mutate();
} else {
setComments(comments.filter((item) => item.comment_id !== id));
}
setComments(comments.filter((item) => item.comment_id !== id));
});
},
});
@ -258,7 +264,7 @@ const Comment = ({ objectId, mode, commentId }) => {
)}
<div
className="fmt fs-14"
className="fmt fs-14 text-break text-wrap"
dangerouslySetInnerHTML={{ __html: item.parsed_text }}
/>
</div>
@ -302,7 +308,9 @@ const Comment = ({ objectId, mode, commentId }) => {
variant="link"
className="p-0 fs-14 btn-no-border"
onClick={() => {
setVisibleComment(!visibleComment);
if (tryNormalLogged(true)) {
setVisibleComment(!visibleComment);
}
}}>
{t('btn_add_comment')}
</Button>

View File

@ -93,16 +93,18 @@ const Index: FC<Props> = ({
className={classnames(
'fs-14 font-monospace',
newData.original_text && 'mb-4',
)}>
{`/tags/${
newData?.main_tag_slug_name
? diffText(
newData.main_tag_slug_name,
oldData?.main_tag_slug_name,
)
: diffText(newData.slug_name, oldData?.slug_name)
}`}
</div>
)}
dangerouslySetInnerHTML={{
__html: `/tags/${
newData?.main_tag_slug_name
? diffText(
newData.main_tag_slug_name,
oldData?.main_tag_slug_name,
)
: diffText(newData.slug_name, oldData?.slug_name)
}`,
}}
/>
)}
<div
dangerouslySetInnerHTML={{

View File

@ -251,7 +251,7 @@ const Image: FC<IEditorContext> = ({ editor }) => {
<Form.Group controlId="editor.imgDescription" className="mb-3">
<Form.Label>
{t('image.form_image.fields.description.label')}
{t('image.form_image.fields.desc.label')}
</Form.Label>
<Form.Control
type="text"

View File

@ -7,28 +7,31 @@ import {
useImperativeHandle,
} from 'react';
import { marked } from 'marked';
import { markdownToHtml } from '@/services';
import { htmlRender } from './utils';
let scrollTop = 0;
marked.setOptions({
breaks: true,
});
let renderTimer;
const Index = ({ value }, ref) => {
const [html, setHtml] = useState('');
const previewRef = useRef<HTMLDivElement>(null);
const renderMarkdown = (markdown) => {
clearTimeout(renderTimer);
const timeout = renderTimer ? 1000 : 0;
renderTimer = setTimeout(() => {
markdownToHtml(markdown).then((resp) => {
scrollTop = previewRef.current?.scrollTop || 0;
setHtml(resp);
});
}, timeout);
};
useEffect(() => {
const previewHtml = marked(value).replace(
/<img/gi,
'<img referrerpolicy="no-referrer"',
);
scrollTop = previewRef.current?.scrollTop || 0;
setHtml(previewHtml);
renderMarkdown(value);
}, [value]);
useEffect(() => {
if (!html) {
return;

View File

@ -56,6 +56,7 @@ const ToolItem: FC<IProps> = (props) => {
disable ? 'disabled' : ''
} `}
disabled={disable}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
if (typeof onClick === 'function') {

View File

@ -113,4 +113,20 @@ export function htmlRender(el: HTMLElement | null) {
});
},
);
el.querySelectorAll('table').forEach((table) => {
if (
(table.parentNode as HTMLDivElement)?.classList.contains(
'table-responsive',
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
}

View File

@ -5,12 +5,10 @@ import { Trans } from 'react-i18next';
import dayjs from 'dayjs';
import { siteInfoStore } from '@/stores';
import { DEFAULT_SITE_NAME } from '@/common/constants';
const Index = () => {
const fullYear = dayjs().format('YYYY');
const siteName =
siteInfoStore((state) => state.siteInfo.name) || DEFAULT_SITE_NAME;
const siteName = siteInfoStore((state) => state.siteInfo.name);
const cc = `${fullYear} ${siteName}`;
return (
<footer className="bg-light py-3">

View File

@ -1,7 +1,7 @@
import { FC, memo } from 'react';
import { Nav, Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link, NavLink } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import type * as Type from '@/common/interface';
import { Avatar, Icon } from '@/components';
@ -14,6 +14,13 @@ interface Props {
const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleLinkClick = (evt) => {
evt.preventDefault();
const { href } = evt.currentTarget;
const { pathname } = new URL(href);
navigate(pathname);
};
return (
<>
<Nav className="flex-row">
@ -26,7 +33,7 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
</Nav.Link>
<Nav.Link
as={Link}
as={NavLink}
to="/users/notifications/achievement"
className="icon-link d-flex align-items-center justify-content-center p-0 me-3 position-relative">
<Icon name="trophy-fill" className="fs-4" />
@ -46,17 +53,26 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item href={`/users/${userInfo.username}`}>
<Dropdown.Item
href={`/users/${userInfo.username}`}
onClick={handleLinkClick}>
{t('header.nav.profile')}
</Dropdown.Item>
<Dropdown.Item href="/users/settings/profile">
<Dropdown.Item
href="/users/settings/profile"
onClick={handleLinkClick}>
{t('header.nav.setting')}
</Dropdown.Item>
{userInfo?.is_admin ? (
<Dropdown.Item href="/admin">{t('header.nav.admin')}</Dropdown.Item>
<Dropdown.Item href="/admin" onClick={handleLinkClick}>
{t('header.nav.admin')}
</Dropdown.Item>
) : null}
{redDot?.can_revision ? (
<Dropdown.Item href="/review" className="position-relative">
<Dropdown.Item
href="/review"
className="position-relative"
onClick={handleLinkClick}>
{t('header.nav.review')}
{redDot?.revision > 0 && (
<span className="position-absolute top-50 translate-middle-y end-0 me-3 p-2 bg-danger border border-light rounded-circle">

View File

@ -19,6 +19,7 @@ import {
import classnames from 'classnames';
import { floppyNavigation } from '@/utils';
import {
loggedUserInfoStore,
siteInfoStore,
@ -27,8 +28,6 @@ import {
themeSettingStore,
} from '@/stores';
import { logout, useQueryNotificationStatus } from '@/services';
import { RouteAlias } from '@/router/alias';
import { DEFAULT_SITE_NAME } from '@/common/constants';
import NavItems from './components/NavItems';
@ -36,7 +35,7 @@ import './index.scss';
const Header: FC = () => {
const navigate = useNavigate();
const { user, clear } = loggedUserInfoStore();
const { user, clear: clearUserStore } = loggedUserInfoStore();
const { t } = useTranslation();
const [urlSearch] = useSearchParams();
const q = urlSearch.get('q');
@ -49,11 +48,25 @@ const Header: FC = () => {
const handleInput = (val) => {
setSearch(val);
};
const handleSearch = (evt) => {
evt.preventDefault();
if (!searchStr) {
return;
}
const searchUrl = `/search?q=${encodeURIComponent(searchStr)}`;
navigate(searchUrl);
};
const handleLogout = async () => {
await logout();
clear();
navigate(RouteAlias.home);
clearUserStore();
window.location.replace(window.location.href);
};
const onLoginClick = (evt) => {
evt.preventDefault();
floppyNavigation.navigateToLogin((loginPath) => {
navigate(loginPath, { replace: true });
});
};
useEffect(() => {
@ -108,7 +121,7 @@ const Header: FC = () => {
/>
</>
) : (
<span>{siteInfo.name || DEFAULT_SITE_NAME}</span>
<span>{siteInfo.name}</span>
)}
</Navbar.Brand>
@ -118,7 +131,14 @@ const Header: FC = () => {
<NavItems redDot={redDot} userInfo={user} logOut={handleLogout} />
) : (
<>
<Button variant="link" className="me-2" href="/users/login">
<Button
variant="link"
className={classnames('me-2', {
'link-light': navbarStyle === 'theme-colored',
'link-primary': navbarStyle !== 'theme-colored',
})}
onClick={onLoginClick}
href="/users/login">
{t('btns.login')}
</Button>
{loginSetting.allow_new_registrations && (
@ -153,7 +173,10 @@ const Header: FC = () => {
<hr className="hr lg-none mt-2" />
<Col lg={4} className="d-flex justify-content-center">
<Form action="/search" className="w-75 px-0 px-lg-2">
<Form
action="/search"
className="w-75 px-0 px-lg-2"
onSubmit={handleSearch}>
<FormControl
placeholder={t('header.search.placeholder')}
className="placeholder-search"
@ -202,6 +225,7 @@ const Header: FC = () => {
'link-light': navbarStyle === 'theme-colored',
'link-primary': navbarStyle !== 'theme-colored',
})}
onClick={onLoginClick}
href="/users/login">
{t('btns.login')}
</Button>

View File

@ -0,0 +1,56 @@
import React from 'react';
import { Modal } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { loginToContinueStore, siteInfoStore } from '@/stores';
interface IProps {
visible: boolean;
}
const Index: React.FC<IProps> = ({ visible = false }) => {
const { t } = useTranslation('translation', { keyPrefix: 'login' });
const { update: updateStore } = loginToContinueStore();
const { siteInfo } = siteInfoStore((_) => _);
const closeModal = () => {
updateStore({ show: false });
};
const linkClick = (evt) => {
evt.stopPropagation();
closeModal();
};
return (
<Modal
title="LoginToContinue"
show={visible}
onHide={closeModal}
centered
fullscreen="sm-down">
<Modal.Header closeButton>
<Modal.Title as="h5">{t('login_to_continue')}</Modal.Title>
</Modal.Header>
<Modal.Body className="p-5">
<div className="d-flex flex-column align-items-center text-center text-body">
<h3>{t('page_title', { site_name: siteInfo.name })}</h3>
<p>{siteInfo.description}</p>
</div>
<div className="d-grid gap-2">
<Link
to="/users/login"
className="btn btn-primary"
onClick={linkClick}>
{t('login', { keyPrefix: 'btns' })}
</Link>
<Link
to="/users/register"
className="btn btn-link"
onClick={linkClick}>
{t('signup', { keyPrefix: 'btns' })}
</Link>
</div>
</Modal.Body>
</Modal>
);
};
export default Index;

View File

@ -1,6 +1,7 @@
import DefaultModal from './Modal';
import confirm, { Config } from './Confirm';
import PicAuthCodeModal from './PicAuthCodeModal';
import LoginToContinueModal from './LoginToContinueModal';
type ModalType = typeof DefaultModal & {
confirm: (config: Config) => void;
@ -14,3 +15,4 @@ Modal.confirm = function (props: Config) {
export default Modal;
export { PicAuthCodeModal };
export { LoginToContinueModal };

View File

@ -41,10 +41,10 @@ const Index: FC<IProps> = ({
const navigate = useNavigate();
const reportModal = useReportModal();
const refershQuestion = () => {
const refreshQuestion = () => {
callback?.('default');
};
const closeModal = useReportModal(refershQuestion);
const closeModal = useReportModal(refreshQuestion);
const editUrl =
type === 'answer' ? `/posts/${qid}/${aid}/edit` : `/posts/${qid}/edit`;
@ -97,7 +97,7 @@ const Index: FC<IProps> = ({
deleteAnswer({
id: aid,
}).then(() => {
// refersh page
// refresh page
toast.onShow({
msg: t('tip_answer_deleted'),
variant: 'success',
@ -132,7 +132,7 @@ const Index: FC<IProps> = ({
msg: t('success', { keyPrefix: 'question_detail.reopen' }),
variant: 'success',
});
refershQuestion();
refreshQuestion();
});
},
});

View File

@ -13,6 +13,7 @@ import {
Empty,
BaseUserCard,
QueryGroup,
QuestionListLoader,
} from '@/components';
import { useQuestionList } from '@/services';
@ -28,60 +29,6 @@ interface Props {
source: 'questions' | 'tag';
}
const QuestionLastUpdate = ({ q }) => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
if (q.update_time > q.edit_time) {
// question answered
return (
<div className="d-flex">
<BaseUserCard
data={q.last_answered_user_info}
showAvatar={false}
className="me-1"
/>
<FormatTime
time={q.update_time}
className="text-secondary ms-1"
preFix={t('answered')}
/>
</div>
);
}
if (q.edit_time > q.update_time) {
// question modified
return (
<div className="d-flex">
<BaseUserCard
data={q.update_user_info}
showAvatar={false}
className="me-1"
/>
<FormatTime
time={q.edit_time}
className="text-secondary ms-1"
preFix={t('modified')}
/>
</div>
);
}
// default: asked
return (
<div className="d-flex">
<BaseUserCard data={q.user_info} showAvatar={false} className="me-1" />
<FormatTime
time={q.create_time}
preFix={t('asked')}
className="text-secondary ms-1"
/>
</div>
);
};
const QuestionList: FC<Props> = ({ source }) => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const { tagName = '' } = useParams();
@ -118,57 +65,73 @@ const QuestionList: FC<Props> = ({ source }) => {
/>
</div>
<ListGroup className="rounded-0">
{listData?.list?.map((li) => {
return (
<ListGroup.Item
key={li.id}
className="bg-transparent py-3 px-0 border-start-0 border-end-0">
<h5 className="text-wrap text-break">
<NavLink
to={pathFactory.questionLanding(li.id, li.url_title)}
className="link-dark">
{li.title}
{li.status === 2 ? ` [${t('closed')}]` : ''}
</NavLink>
</h5>
<div className="d-flex flex-column flex-md-row align-items-md-center fs-14 mb-2 text-secondary">
<QuestionLastUpdate q={li} />
<div className="ms-0 ms-md-3 mt-2 mt-md-0">
<span>
<Icon name="hand-thumbs-up-fill" />
<em className="fst-normal ms-1">{li.vote_count}</em>
</span>
<span
className={`ms-3 ${
li.accepted_answer_id >= 1 ? 'text-success' : ''
}`}>
<Icon
name={
li.accepted_answer_id >= 1
? 'check-circle-fill'
: 'chat-square-text-fill'
}
{isLoading ? (
<QuestionListLoader />
) : (
listData?.list?.map((li) => {
return (
<ListGroup.Item
key={li.id}
className="bg-transparent py-3 px-0 border-start-0 border-end-0">
<h5 className="text-wrap text-break">
<NavLink
to={pathFactory.questionLanding(li.id, li.url_title)}
className="link-dark">
{li.title}
{li.status === 2 ? ` [${t('closed')}]` : ''}
</NavLink>
</h5>
<div className="d-flex flex-column flex-md-row align-items-md-center fs-14 mb-2 text-secondary">
<div className="d-flex">
<BaseUserCard
data={li.operator}
showAvatar={false}
className="me-1"
/>
<em className="fst-normal ms-1">{li.answer_count}</em>
</span>
<span className="summary-stat ms-3">
<Icon name="eye-fill" />
<em className="fst-normal ms-1">{li.view_count}</em>
</span>
<FormatTime
time={li.operated_at}
className="text-secondary ms-1"
preFix={t(li.operation_type)}
/>
</div>
<div className="ms-0 ms-md-3 mt-2 mt-md-0">
<span>
<Icon name="hand-thumbs-up-fill" />
<em className="fst-normal ms-1">{li.vote_count}</em>
</span>
<span
className={`ms-3 ${
li.accepted_answer_id >= 1 ? 'text-success' : ''
}`}>
<Icon
name={
li.accepted_answer_id >= 1
? 'check-circle-fill'
: 'chat-square-text-fill'
}
/>
<em className="fst-normal ms-1">{li.answer_count}</em>
</span>
<span className="summary-stat ms-3">
<Icon name="eye-fill" />
<em className="fst-normal ms-1">{li.view_count}</em>
</span>
</div>
</div>
</div>
<div className="question-tags m-n1">
{Array.isArray(li.tags)
? li.tags.map((tag) => {
return (
<Tag key={tag.slug_name} className="m-1" data={tag} />
);
})
: null}
</div>
</ListGroup.Item>
);
})}
<div className="question-tags m-n1">
{Array.isArray(li.tags)
? li.tags.map((tag) => {
return (
<Tag key={tag.slug_name} className="m-1" data={tag} />
);
})
: null}
</div>
</ListGroup.Item>
);
})
)}
</ListGroup>
{count <= 0 && !isLoading && <Empty />}
<div className="mt-4 mb-2 d-flex justify-content-center">

View File

@ -0,0 +1,36 @@
import { FC, memo } from 'react';
import { ListGroupItem } from 'react-bootstrap';
interface Props {
count?: number;
}
const Index: FC<Props> = ({ count = 10 }) => {
const list = new Array(count).fill(0).map((v, i) => v + i);
return (
<>
{list.map((v) => (
<ListGroupItem
className="bg-transparent py-3 px-0 border-start-0 border-end-0 placeholder-glow"
key={v}>
<div
className="placeholder w-100 h5 align-top"
style={{ height: '24px' }}
/>
<div
className="placeholder w-75 d-block align-top mb-2"
style={{ height: '21px' }}
/>
<div
className="placeholder w-50 align-top"
style={{ height: '24px' }}
/>
</ListGroupItem>
))}
</>
);
};
export default memo(Index);

View File

@ -1,4 +1,5 @@
import React, { memo, FC } from 'react';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
@ -21,8 +22,8 @@ const Index: FC<IProps> = ({
href ||= pathFactory.tagLanding(data?.slug_name);
return (
<a
href={href}
<Link
to={href}
className={classNames(
'badge-tag rounded-1',
data.reserved && 'badge-tag-reserved',
@ -30,7 +31,7 @@ const Index: FC<IProps> = ({
className,
)}>
<span className={textClassName}>{data.slug_name}</span>
</a>
</Link>
);
};

View File

@ -1,10 +1,4 @@
.tag-selector-wrap {
.btn {
&.warning {
-webkit-animation: tag-input-warning 2s;
animation: tag-input-warning 2s;
}
}
.dropdown-menu {
min-width: 15rem;
}
@ -18,23 +12,5 @@
color: #212529;
background-color: #e9ecef;
}
@-webkit-keyframes tag-input-warning {
0% {
background-color: #ffc107;
}
100% {
background-color: #f8f9fa;
}
}
@keyframes tag-input-warning {
0% {
background-color: #ffc107;
}
100% {
background-color: #f8f9fa;
}
}
}

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