Merge remote-tracking branch 'origin/beta.2/1.1.0' into feat/plugin/search

This commit is contained in:
LinkinStars 2023-05-17 11:44:19 +08:00
commit 5b7b49f87d
96 changed files with 1735 additions and 914 deletions

13
.github/Dockerfile vendored
View File

@ -1,9 +1,10 @@
FROM amd64/node AS node-builder
FROM amd64/node:18 AS node-builder
LABEL maintainer="mingcheng<mc@sf.com>"
COPY . /answer
WORKDIR /answer
RUN node -v
RUN make install-ui-packages ui && mv ui/build /tmp
# stage2 build the main binary within static resource
@ -11,12 +12,14 @@ FROM golang:1.19-alpine AS golang-builder
LABEL maintainer="aichy@sf.com"
ARG GOPROXY
# ENV GOPROXY ${GOPROXY:-direct}
ENV GOPROXY=https://goproxy.io,direct
ENV GOPATH /go
ENV GOROOT /usr/local/go
ENV PACKAGE github.com/answerdev/answer
ENV BUILD_DIR ${GOPATH}/src/${PACKAGE}
ENV ANSWER_MODULE ${BUILD_DIR}
ARG TAGS="sqlite sqlite_unlock_notify"
ENV TAGS "bindata timetzdata $TAGS"
@ -25,9 +28,11 @@ ARG CGO_EXTRA_CFLAGS
COPY . ${BUILD_DIR}
WORKDIR ${BUILD_DIR}
COPY --from=node-builder /tmp/build ${BUILD_DIR}/ui/build
RUN apk --no-cache add build-base git \
&& make clean build \
&& cp answer /usr/bin/answer
RUN apk --no-cache add build-base git bash \
&& make clean build
RUN chmod 755 answer
RUN ["/bin/bash","-c","script/build_plugin.sh"]
RUN cp answer /usr/bin/answer
RUN mkdir -p /data/uploads && chmod 777 /data/uploads \
&& mkdir -p /data/i18n && cp -r i18n/*.yaml /data/i18n

71
.github/workflows/manual_build.yml vendored Normal file
View File

@ -0,0 +1,71 @@
name: Manual Build DockerHub Image
on:
workflow_dispatch:
inputs:
tag_name:
type: string
required: true
description: 'DockerHub img tag name'
logLevel:
description: 'Log level'
required: true
default: 'warning'
type: choice
options:
- info
- warning
- debug
tags:
description: 'Test scenario tags'
required: false
type: boolean
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: answerdev/answer
tags: |
type=ref,enable=true,priority=600,prefix=,suffix=,event=branch
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# - name: Login to GitHub Container Registry
# uses: docker/login-action@v2
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
file: ./.github/Dockerfile
tags: answerdev/answer:${{ inputs.tag_name }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -1,9 +1,11 @@
FROM amd64/node AS node-builder
# FROM amd64/node AS node-builder
FROM amd64/node:18 AS node-builder
LABEL maintainer="mingcheng<mc@sf.com>"
COPY . /answer
WORKDIR /answer
RUN node -v
RUN make install-ui-packages ui && mv ui/build /tmp
# stage2 build the main binary within static resource
@ -11,12 +13,14 @@ FROM golang:1.19-alpine AS golang-builder
LABEL maintainer="aichy@sf.com"
ARG GOPROXY
ENV GOPROXY ${GOPROXY:-direct}
# ENV GOPROXY ${GOPROXY:-direct}
ENV GOPROXY=https://goproxy.io,direct
ENV GOPATH /go
ENV GOROOT /usr/local/go
ENV PACKAGE github.com/answerdev/answer
ENV BUILD_DIR ${GOPATH}/src/${PACKAGE}
ENV ANSWER_MODULE ${BUILD_DIR}
ARG TAGS="sqlite sqlite_unlock_notify"
ENV TAGS "bindata timetzdata $TAGS"

View File

@ -1,6 +1,7 @@
package answercmd
import (
"fmt"
"os"
"time"
@ -56,6 +57,7 @@ func runApp() {
constant.Version = Version
constant.Revision = Revision
schema.AppStartTime = time.Now()
fmt.Println("answer Version:", constant.Version, " Revision:", constant.Revision)
defer cleanup()
if err := app.Run(); err != nil {

View File

@ -1,5 +1,6 @@
# url path reserves the keywords list
users:
- unsubscribe
- settings
- login
- register
@ -9,5 +10,7 @@ users:
- account-activation
- confirm-new-email
- account-suspended
- confirm-email
- auth-landing
questions:
- ask

View File

@ -3003,7 +3003,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "UserAnswerList",
"description": "list personal answers",
"consumes": [
"application/json"
],
@ -3011,9 +3011,9 @@ const docTemplate = `{
"application/json"
],
"tags": [
"api-answer"
"Personal"
],
"summary": "UserAnswerList",
"summary": "list personal answers",
"parameters": [
{
"type": "string",
@ -3045,8 +3045,8 @@ const docTemplate = `{
{
"type": "string",
"default": "20",
"description": "pagesize",
"name": "pagesize",
"description": "page_size",
"name": "page_size",
"in": "query",
"required": true
}
@ -3068,7 +3068,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "UserCollectionList",
"description": "list personal collections",
"consumes": [
"application/json"
],
@ -3078,7 +3078,7 @@ const docTemplate = `{
"tags": [
"Collection"
],
"summary": "UserCollectionList",
"summary": "list personal collections",
"parameters": [
{
"type": "string",
@ -3091,8 +3091,8 @@ const docTemplate = `{
{
"type": "string",
"default": "20",
"description": "pagesize",
"name": "pagesize",
"description": "page_size",
"name": "page_size",
"in": "query",
"required": true
}
@ -5289,12 +5289,12 @@ const docTemplate = `{
"summary": "UserModifyPassWord",
"parameters": [
{
"description": "UserModifyPassWordRequest",
"description": "UserModifyPasswordReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.UserModifyPassWordRequest"
"$ref": "#/definitions/schema.UserModifyPasswordReq"
}
}
],
@ -5810,7 +5810,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "UserList",
"description": "list personal questions",
"consumes": [
"application/json"
],
@ -5818,9 +5818,9 @@ const docTemplate = `{
"application/json"
],
"tags": [
"Question"
"Personal"
],
"summary": "UserList",
"summary": "list personal questions",
"parameters": [
{
"type": "string",
@ -5852,8 +5852,8 @@ const docTemplate = `{
{
"type": "string",
"default": "20",
"description": "pagesize",
"name": "pagesize",
"description": "page_size",
"name": "page_size",
"in": "query",
"required": true
}
@ -6385,19 +6385,11 @@ const docTemplate = `{
}
}
},
"schema.ConfigFieldUIOptionAction": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
}
},
"schema.ConfigFieldUIOptions": {
"type": "object",
"properties": {
"action": {
"$ref": "#/definitions/schema.ConfigFieldUIOptionAction"
"$ref": "#/definitions/schema.UIOptionAction"
},
"input_type": {
"type": "string"
@ -7484,6 +7476,17 @@ const docTemplate = `{
}
}
},
"schema.LoadingAction": {
"type": "object",
"properties": {
"state": {
"type": "string"
},
"text": {
"type": "string"
}
}
},
"schema.NotificationClearIDRequest": {
"type": "object",
"properties": {
@ -7501,6 +7504,17 @@ const docTemplate = `{
}
}
},
"schema.OnCompleteAction": {
"type": "object",
"properties": {
"refresh_form_config": {
"type": "boolean"
},
"toast_return_message": {
"type": "boolean"
}
}
},
"schema.OperationQuestionReq": {
"type": "object",
"required": [
@ -8489,6 +8503,23 @@ const docTemplate = `{
}
}
},
"schema.UIOptionAction": {
"type": "object",
"properties": {
"loading": {
"$ref": "#/definitions/schema.LoadingAction"
},
"method": {
"type": "string"
},
"on_complete": {
"$ref": "#/definitions/schema.OnCompleteAction"
},
"url": {
"type": "string"
}
}
},
"schema.UnreviewedRevisionInfoInfo": {
"type": "object",
"properties": {
@ -8545,9 +8576,6 @@ const docTemplate = `{
},
"schema.UpdateInfoRequest": {
"type": "object",
"required": [
"display_name"
],
"properties": {
"avatar": {
"description": "avatar",
@ -8843,6 +8871,11 @@ const docTemplate = `{
"e_mail": {
"type": "string",
"maxLength": 500
},
"pass": {
"type": "string",
"maxLength": 32,
"minLength": 8
}
}
},
@ -8886,7 +8919,7 @@ const docTemplate = `{
}
}
},
"schema.UserModifyPassWordRequest": {
"schema.UserModifyPasswordReq": {
"type": "object",
"required": [
"pass"

View File

@ -2991,7 +2991,7 @@
"ApiKeyAuth": []
}
],
"description": "UserAnswerList",
"description": "list personal answers",
"consumes": [
"application/json"
],
@ -2999,9 +2999,9 @@
"application/json"
],
"tags": [
"api-answer"
"Personal"
],
"summary": "UserAnswerList",
"summary": "list personal answers",
"parameters": [
{
"type": "string",
@ -3033,8 +3033,8 @@
{
"type": "string",
"default": "20",
"description": "pagesize",
"name": "pagesize",
"description": "page_size",
"name": "page_size",
"in": "query",
"required": true
}
@ -3056,7 +3056,7 @@
"ApiKeyAuth": []
}
],
"description": "UserCollectionList",
"description": "list personal collections",
"consumes": [
"application/json"
],
@ -3066,7 +3066,7 @@
"tags": [
"Collection"
],
"summary": "UserCollectionList",
"summary": "list personal collections",
"parameters": [
{
"type": "string",
@ -3079,8 +3079,8 @@
{
"type": "string",
"default": "20",
"description": "pagesize",
"name": "pagesize",
"description": "page_size",
"name": "page_size",
"in": "query",
"required": true
}
@ -5277,12 +5277,12 @@
"summary": "UserModifyPassWord",
"parameters": [
{
"description": "UserModifyPassWordRequest",
"description": "UserModifyPasswordReq",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.UserModifyPassWordRequest"
"$ref": "#/definitions/schema.UserModifyPasswordReq"
}
}
],
@ -5798,7 +5798,7 @@
"ApiKeyAuth": []
}
],
"description": "UserList",
"description": "list personal questions",
"consumes": [
"application/json"
],
@ -5806,9 +5806,9 @@
"application/json"
],
"tags": [
"Question"
"Personal"
],
"summary": "UserList",
"summary": "list personal questions",
"parameters": [
{
"type": "string",
@ -5840,8 +5840,8 @@
{
"type": "string",
"default": "20",
"description": "pagesize",
"name": "pagesize",
"description": "page_size",
"name": "page_size",
"in": "query",
"required": true
}
@ -6373,19 +6373,11 @@
}
}
},
"schema.ConfigFieldUIOptionAction": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
}
},
"schema.ConfigFieldUIOptions": {
"type": "object",
"properties": {
"action": {
"$ref": "#/definitions/schema.ConfigFieldUIOptionAction"
"$ref": "#/definitions/schema.UIOptionAction"
},
"input_type": {
"type": "string"
@ -7472,6 +7464,17 @@
}
}
},
"schema.LoadingAction": {
"type": "object",
"properties": {
"state": {
"type": "string"
},
"text": {
"type": "string"
}
}
},
"schema.NotificationClearIDRequest": {
"type": "object",
"properties": {
@ -7489,6 +7492,17 @@
}
}
},
"schema.OnCompleteAction": {
"type": "object",
"properties": {
"refresh_form_config": {
"type": "boolean"
},
"toast_return_message": {
"type": "boolean"
}
}
},
"schema.OperationQuestionReq": {
"type": "object",
"required": [
@ -8477,6 +8491,23 @@
}
}
},
"schema.UIOptionAction": {
"type": "object",
"properties": {
"loading": {
"$ref": "#/definitions/schema.LoadingAction"
},
"method": {
"type": "string"
},
"on_complete": {
"$ref": "#/definitions/schema.OnCompleteAction"
},
"url": {
"type": "string"
}
}
},
"schema.UnreviewedRevisionInfoInfo": {
"type": "object",
"properties": {
@ -8533,9 +8564,6 @@
},
"schema.UpdateInfoRequest": {
"type": "object",
"required": [
"display_name"
],
"properties": {
"avatar": {
"description": "avatar",
@ -8831,6 +8859,11 @@
"e_mail": {
"type": "string",
"maxLength": 500
},
"pass": {
"type": "string",
"maxLength": 32,
"minLength": 8
}
}
},
@ -8874,7 +8907,7 @@
}
}
},
"schema.UserModifyPassWordRequest": {
"schema.UserModifyPasswordReq": {
"type": "object",
"required": [
"pass"

View File

@ -341,15 +341,10 @@ definitions:
value:
type: string
type: object
schema.ConfigFieldUIOptionAction:
properties:
url:
type: string
type: object
schema.ConfigFieldUIOptions:
properties:
action:
$ref: '#/definitions/schema.ConfigFieldUIOptionAction'
$ref: '#/definitions/schema.UIOptionAction'
input_type:
type: string
label:
@ -1130,6 +1125,13 @@ definitions:
description: vote type
type: string
type: object
schema.LoadingAction:
properties:
state:
type: string
text:
type: string
type: object
schema.NotificationClearIDRequest:
properties:
id:
@ -1141,6 +1143,13 @@ definitions:
description: inbox achievement
type: string
type: object
schema.OnCompleteAction:
properties:
refresh_form_config:
type: boolean
toast_return_message:
type: boolean
type: object
schema.OperationQuestionReq:
properties:
id:
@ -1827,6 +1836,17 @@ definitions:
value:
type: string
type: object
schema.UIOptionAction:
properties:
loading:
$ref: '#/definitions/schema.LoadingAction'
method:
type: string
on_complete:
$ref: '#/definitions/schema.OnCompleteAction'
url:
type: string
type: object
schema.UnreviewedRevisionInfoInfo:
properties:
content:
@ -1889,8 +1909,6 @@ definitions:
description: website
maxLength: 500
type: string
required:
- display_name
type: object
schema.UpdatePluginConfigReq:
properties:
@ -2078,6 +2096,10 @@ definitions:
e_mail:
maxLength: 500
type: string
pass:
maxLength: 32
minLength: 8
type: string
required:
- e_mail
type: object
@ -2110,7 +2132,7 @@ definitions:
- e_mail
- pass
type: object
schema.UserModifyPassWordRequest:
schema.UserModifyPasswordReq:
properties:
old_pass:
maxLength: 32
@ -4050,7 +4072,7 @@ paths:
get:
consumes:
- application/json
description: UserAnswerList
description: list personal answers
parameters:
- default: string
description: username
@ -4073,9 +4095,9 @@ paths:
required: true
type: string
- default: "20"
description: pagesize
description: page_size
in: query
name: pagesize
name: page_size
required: true
type: string
produces:
@ -4087,14 +4109,14 @@ paths:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: UserAnswerList
summary: list personal answers
tags:
- api-answer
- Personal
/answer/api/v1/personal/collection/page:
get:
consumes:
- application/json
description: UserCollectionList
description: list personal collections
parameters:
- default: "0"
description: page
@ -4103,9 +4125,9 @@ paths:
required: true
type: string
- default: "20"
description: pagesize
description: page_size
in: query
name: pagesize
name: page_size
required: true
type: string
produces:
@ -4117,7 +4139,7 @@ paths:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: UserCollectionList
summary: list personal collections
tags:
- Collection
/answer/api/v1/personal/comment/page:
@ -5434,12 +5456,12 @@ paths:
- application/json
description: UserModifyPassWord
parameters:
- description: UserModifyPassWordRequest
- description: UserModifyPasswordReq
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.UserModifyPassWordRequest'
$ref: '#/definitions/schema.UserModifyPasswordReq'
produces:
- application/json
responses:
@ -5751,7 +5773,7 @@ paths:
get:
consumes:
- application/json
description: UserList
description: list personal questions
parameters:
- default: string
description: username
@ -5774,9 +5796,9 @@ paths:
required: true
type: string
- default: "20"
description: pagesize
description: page_size
in: query
name: pagesize
name: page_size
required: true
type: string
produces:
@ -5788,9 +5810,9 @@ paths:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: UserList
summary: list personal questions
tags:
- Question
- Personal
/robots.txt:
get:
description: get site robots information

3
go.mod
View File

@ -42,7 +42,7 @@ require (
github.com/swaggo/swag v1.8.10
github.com/tidwall/gjson v1.14.4
github.com/yuin/goldmark v1.4.13
golang.org/x/crypto v0.4.0
golang.org/x/crypto v0.1.0
golang.org/x/net v0.7.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1
@ -102,7 +102,6 @@ require (
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/onsi/gomega v1.20.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/opencontainers/runc v1.1.2 // indirect

7
go.sum
View File

@ -538,9 +538,8 @@ github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:v
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.20.1 h1:PA/3qinGoukvymdIDV8pii6tiZgC8kbmJO6Z5+b002Q=
github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
@ -782,8 +781,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=

View File

@ -12,6 +12,8 @@ backend:
other: Unauthorized.
database_error:
other: Data server error.
forbidden_error:
other: Forbidden.
action:
report:
other: Flag
@ -339,6 +341,16 @@ backend:
other: Your answer has been deleted
your_comment_was_deleted:
other: Your comment has been deleted
up_voted_question:
other: upvoted question
down_voted_question:
other: downvoted question
up_voted_answer:
other: upvoted answer
down_voted_answer:
other: downvoted answer
up_voted_comment:
other: upvoted comment
# The following fields are used for interface presentation(Front-end)
ui:
@ -394,6 +406,7 @@ ui:
achievement: Achievements
all_read: Mark all as read
show_more: Show more
someone: Someone
suspended:
title: Your Account has been Suspended
until_time: "Your account was suspended until {{ time }}."
@ -611,7 +624,7 @@ ui:
tip_answer: >-
Use comments to reply to other users or notify them of changes. If you are
adding new information, edit your post instead of commenting.
tip_vote: It adds something useful to the post
tip_vote: It adds something useful to the post
edit_answer:
title: Edit Answer
default_reason: Edit answer
@ -843,8 +856,11 @@ ui:
We've sent an email to that address. Please follow the confirmation
instructions.
email:
label: Email
msg: Email cannot be empty.
label: New Email
msg: New Email cannot be empty.
pass:
label: Current Password
msg: Password cannot be empty.
password_title: Password
current_pass:
label: Current Password
@ -891,8 +907,8 @@ ui:
closed_in: Closed in
show_exist: Show existing question.
useful: Useful
question_useful: It is useful and clear
question_un_useful: It is unclear or not useful
question_useful: It is useful and clear
question_un_useful: It is unclear or not useful
answer_useful: It is useful
answer_un_useful: It is not useful
answers:
@ -955,6 +971,10 @@ ui:
skip: Skip
discard_draft: Discard draft
pinned: Pinned
all: All
question: Question
answer: Answer
comment: Comment
search:
title: Search Results
keywords: Keywords
@ -1233,6 +1253,7 @@ ui:
pending: Pending
completed: Completed
flagged: Flagged
flagged_type: Flagged {{ type }}
created: Created
action: Action
review: Review

View File

@ -329,6 +329,16 @@ backend:
other: 你的答案已被删除
your_comment_was_deleted:
other: 你的评论已被删除
up_voted_question:
other: 赞了问题
down_voted_question:
other: 踩了问题
up_voted_answer:
other: 赞了答案
down_voted_answer:
other: 踩了答案
up_voted_comment:
other: 赞了评论
#The following fields are used for interface presentation(Front-end)
ui:
how_to_format:
@ -374,6 +384,7 @@ ui:
achievement: 成就
all_read: 全部标记为已读
show_more: 显示更多
someone: 有人
suspended:
title: 账号已封禁
until_time: "你的账号被封禁至{{ time }}。"
@ -895,6 +906,10 @@ ui:
skip: 略过
discard_draft: 丢弃草稿
pinned: 已置顶
all: 所有
question: 问题
answer: 回答
comment: 评论
search:
title: 搜索结果
keywords: 关键词
@ -1164,6 +1179,7 @@ ui:
pending: 等待处理
completed: 已完成
flagged: 被举报内容
flagged_type: 被举报的{{ type }}
created: 创建于
action: 操作
review: 审查
@ -1451,6 +1467,30 @@ ui:
deactivate: 停用
activate: 启用
settings: 设置
settings_users:
title: 用户
avatar:
label: 默认头像
text: 未设置自定义头像的用户所展示的头像。
profile_editable:
title: 可编辑的个人资料
allow_update_display_name:
label: 允许用户更改显示名称
allow_update_username:
label: 允许用户更改用户名
allow_update_avatar:
label: 允许用户更改头像
allow_update_bio:
label: 允许用户更改自我介绍
allow_update_website:
label: 允许用户更改个人网站
allow_update_location:
label: 允许用户更改所在地
privilege:
title: 声望权限
level:
label: 所需声望等级
text: 选择所需的声望等级以获取权限
form:
optional: (选填)
empty: 不能为空

View File

@ -1,28 +1,38 @@
package constant
const (
// UpdateQuestion update question
UpdateQuestion = "notification.action.update_question"
// AnswerTheQuestion answer the question
AnswerTheQuestion = "notification.action.answer_the_question"
// UpdateAnswer update answer
UpdateAnswer = "notification.action.update_answer"
// AcceptAnswer accept answer
AcceptAnswer = "notification.action.accept_answer"
// CommentQuestion comment question
CommentQuestion = "notification.action.comment_question"
// CommentAnswer comment answer
CommentAnswer = "notification.action.comment_answer"
// ReplyToYou reply to you
ReplyToYou = "notification.action.reply_to_you"
// MentionYou mention you
MentionYou = "notification.action.mention_you"
// YourQuestionIsClosed your question is closed
YourQuestionIsClosed = "notification.action.your_question_is_closed"
// YourQuestionWasDeleted your question was deleted
YourQuestionWasDeleted = "notification.action.your_question_was_deleted"
// YourAnswerWasDeleted your answer was deleted
YourAnswerWasDeleted = "notification.action.your_answer_was_deleted"
// YourCommentWasDeleted your comment was deleted
YourCommentWasDeleted = "notification.action.your_comment_was_deleted"
// NotificationUpdateQuestion update question
NotificationUpdateQuestion = "notification.action.update_question"
// NotificationAnswerTheQuestion answer the question
NotificationAnswerTheQuestion = "notification.action.answer_the_question"
// NotificationUpVotedTheQuestion up voted the question
NotificationUpVotedTheQuestion = "notification.action.up_voted_question"
// NotificationDownVotedTheQuestion down voted the question
NotificationDownVotedTheQuestion = "notification.action.down_voted_question"
// NotificationUpdateAnswer update answer
NotificationUpdateAnswer = "notification.action.update_answer"
// NotificationAcceptAnswer accept answer
NotificationAcceptAnswer = "notification.action.accept_answer"
// NotificationUpVotedTheAnswer up voted the answer
NotificationUpVotedTheAnswer = "notification.action.up_voted_answer"
// NotificationDownVotedTheAnswer down voted the answer
NotificationDownVotedTheAnswer = "notification.action.down_voted_answer"
// NotificationCommentQuestion comment question
NotificationCommentQuestion = "notification.action.comment_question"
// NotificationCommentAnswer comment answer
NotificationCommentAnswer = "notification.action.comment_answer"
// NotificationUpVotedTheComment up voted the comment
NotificationUpVotedTheComment = "notification.action.up_voted_comment"
// NotificationReplyToYou reply to you
NotificationReplyToYou = "notification.action.reply_to_you"
// NotificationMentionYou mention you
NotificationMentionYou = "notification.action.mention_you"
// NotificationYourQuestionIsClosed your question is closed
NotificationYourQuestionIsClosed = "notification.action.your_question_is_closed"
// NotificationYourQuestionWasDeleted your question was deleted
NotificationYourQuestionWasDeleted = "notification.action.your_question_was_deleted"
// NotificationYourAnswerWasDeleted your answer was deleted
NotificationYourAnswerWasDeleted = "notification.action.your_answer_was_deleted"
// NotificationYourCommentWasDeleted your comment was deleted
NotificationYourCommentWasDeleted = "notification.action.your_comment_was_deleted"
)

View File

@ -1,5 +1,6 @@
package constant
var (
DefaultAvatar = "system"
DefaultAvatar = "system"
DefaultSiteURL = ""
)

View File

@ -0,0 +1,15 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
func HeadersByRequestURI() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
c.Header("cache-control", "public, max-age=31536000")
}
}
}

View File

@ -36,7 +36,7 @@ func NewHTTPServer(debug bool,
html, _ := fs.Sub(ui.Template, "template")
htmlTemplate := template.Must(template.New("").Funcs(funcMap).ParseFS(html, "*"))
r.SetHTMLTemplate(htmlTemplate)
r.Use(middleware.HeadersByRequestURI())
viewRouter.Register(r)
rootGroup := r.Group("")
@ -71,7 +71,7 @@ func NewHTTPServer(debug bool,
pluginAPIRouter.RegisterAuthAdminConnectorRouter(adminauthV1)
_ = plugin.CallAgent(func(agent plugin.Agent) error {
agent.RegisterUnAuthRouter(unAuthV1)
agent.RegisterUnAuthRouter(mustUnAuthV1)
agent.RegisterAuthUserRouter(authV1)
agent.RegisterAuthAdminRouter(adminauthV1)
return nil

View File

@ -11,7 +11,6 @@ import (
"github.com/answerdev/answer/internal/service"
"github.com/answerdev/answer/internal/service/permission"
"github.com/answerdev/answer/internal/service/rank"
"github.com/answerdev/answer/pkg/converter"
"github.com/answerdev/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
@ -552,84 +551,75 @@ func (qc *QuestionController) UserTop(ctx *gin.Context) {
})
}
// UserList godoc
// @Summary UserList
// @Description UserList
// @Tags Question
// PersonalQuestionPage list personal questions
// @Summary list personal questions
// @Description list personal questions
// @Tags Personal
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "username" default(string)
// @Param order query string true "order" Enums(newest,score)
// @Param page query string true "page" default(0)
// @Param pagesize query string true "pagesize" default(20)
// @Param page_size query string true "page_size" default(20)
// @Success 200 {object} handler.RespBody
// @Router /personal/question/page [get]
func (qc *QuestionController) UserList(ctx *gin.Context) {
userName := ctx.Query("username")
order := ctx.Query("order")
pageStr := ctx.Query("page")
pageSizeStr := ctx.Query("pagesize")
page := converter.StringToInt(pageStr)
pageSize := converter.StringToInt(pageSizeStr)
userID := middleware.GetLoginUserIDFromContext(ctx)
questionList, count, err := qc.questionService.SearchUserList(ctx, userName, order, page, pageSize, userID)
handler.HandleResponse(ctx, err, gin.H{
"list": questionList,
"count": count,
})
func (qc *QuestionController) PersonalQuestionPage(ctx *gin.Context) {
req := &schema.PersonalQuestionPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := qc.questionService.PersonalQuestionPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// UserAnswerList godoc
// @Summary UserAnswerList
// @Description UserAnswerList
// @Tags api-answer
// PersonalAnswerPage list personal answers
// @Summary list personal answers
// @Description list personal answers
// @Tags Personal
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "username" default(string)
// @Param order query string true "order" Enums(newest,score)
// @Param page query string true "page" default(0)
// @Param pagesize query string true "pagesize" default(20)
// @Param page_size query string true "page_size" default(20)
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/personal/answer/page [get]
func (qc *QuestionController) UserAnswerList(ctx *gin.Context) {
userName := ctx.Query("username")
order := ctx.Query("order")
pageStr := ctx.Query("page")
pageSizeStr := ctx.Query("pagesize")
page := converter.StringToInt(pageStr)
pageSize := converter.StringToInt(pageSizeStr)
userID := middleware.GetLoginUserIDFromContext(ctx)
questionList, count, err := qc.questionService.SearchUserAnswerList(ctx, userName, order, page, pageSize, userID)
handler.HandleResponse(ctx, err, gin.H{
"list": questionList,
"count": count,
})
func (qc *QuestionController) PersonalAnswerPage(ctx *gin.Context) {
req := &schema.PersonalAnswerPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := qc.questionService.PersonalAnswerPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// UserCollectionList godoc
// @Summary UserCollectionList
// @Description UserCollectionList
// PersonalCollectionPage list personal collections
// @Summary list personal collections
// @Description list personal collections
// @Tags Collection
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param page query string true "page" default(0)
// @Param pagesize query string true "pagesize" default(20)
// @Param page_size query string true "page_size" default(20)
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/personal/collection/page [get]
func (qc *QuestionController) UserCollectionList(ctx *gin.Context) {
pageStr := ctx.Query("page")
pageSizeStr := ctx.Query("pagesize")
page := converter.StringToInt(pageStr)
pageSize := converter.StringToInt(pageSizeStr)
userID := middleware.GetLoginUserIDFromContext(ctx)
questionList, count, err := qc.questionService.SearchUserCollectionList(ctx, page, pageSize, userID)
handler.HandleResponse(ctx, err, gin.H{
"list": questionList,
"count": count,
})
func (qc *QuestionController) PersonalCollectionPage(ctx *gin.Context) {
req := &schema.PersonalCollectionPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := qc.questionService.PersonalCollectionPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// AdminSearchList godoc

View File

@ -184,9 +184,9 @@ func (uc *UserController) UseRePassWord(ctx *gin.Context) {
return
}
resp, err := uc.userService.UseRePassword(ctx, req)
err := uc.userService.UpdatePasswordWhenForgot(ctx, req)
uc.actionService.ActionRecordDel(ctx, schema.ActionRecordTypeFindPass, ctx.ClientIP())
handler.HandleResponse(ctx, err, resp)
handler.HandleResponse(ctx, err, nil)
}
// UserLogout user logout
@ -339,15 +339,16 @@ func (uc *UserController) UserVerifyEmailSend(ctx *gin.Context) {
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.UserModifyPassWordRequest true "UserModifyPassWordRequest"
// @Param data body schema.UserModifyPasswordReq true "UserModifyPasswordReq"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/user/password [put]
func (uc *UserController) UserModifyPassWord(ctx *gin.Context) {
req := &schema.UserModifyPassWordRequest{}
req := &schema.UserModifyPasswordReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.AccessToken = middleware.ExtractToken(ctx)
oldPassVerification, err := uc.userService.UserModifyPassWordVerification(ctx, req)
if err != nil {

View File

@ -80,10 +80,6 @@ func (uc *UserAdminController) UpdateUserRole(ctx *gin.Context) {
// @Success 200 {object} handler.RespBody
// @Router /answer/admin/api/user [post]
func (uc *UserAdminController) AddUser(ctx *gin.Context) {
if plugin.UserCenterEnabled() {
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
return
}
req := &schema.AddUserReq{}
if handler.BindAndCheck(ctx, req) {
return
@ -106,10 +102,6 @@ func (uc *UserAdminController) AddUser(ctx *gin.Context) {
// @Success 200 {object} handler.RespBody
// @Router /answer/admin/api/user/password [put]
func (uc *UserAdminController) UpdateUserPassword(ctx *gin.Context) {
if plugin.UserCenterEnabled() {
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
return
}
req := &schema.UpdateUserPasswordReq{}
if handler.BindAndCheck(ctx, req) {
return

View File

@ -366,10 +366,14 @@ func initConfigTable(engine *xorm.Engine) error {
{ID: 116, Key: "rank.question.reopen", Value: `-1`},
{ID: 117, Key: "rank.tag.use_reserved_tag", Value: `-1`},
{ID: 118, Key: "plugin.status", Value: `{}`},
{ID: 119, Key: "question.pin", Value: `-1`},
{ID: 120, Key: "question.unpin", Value: `-1`},
{ID: 121, Key: "question.show", Value: `-1`},
{ID: 122, Key: "question.hide", Value: `-1`},
{ID: 119, Key: "question.pin", Value: `0`},
{ID: 120, Key: "question.unpin", Value: `0`},
{ID: 121, Key: "question.show", Value: `0`},
{ID: 122, Key: "question.hide", Value: `0`},
{ID: 123, Key: "rank.question.pin", Value: `-1`},
{ID: 124, Key: "rank.question.unpin", Value: `-1`},
{ID: 125, Key: "rank.question.show", Value: `-1`},
{ID: 126, Key: "rank.question.hide", Value: `-1`},
}
_, err := engine.Insert(defaultConfigTable)
return err

View File

@ -59,6 +59,8 @@ var migrations = []Migration{
NewMigration("add user pin hide features", addRolePinAndHideFeatures, true),
NewMigration("update accept answer rank", updateAcceptAnswerRank, true),
NewMigration("add plugin", addPlugin, false),
NewMigration("update user pin hide features", updateRolePinAndHideFeatures, true),
NewMigration("update question post time", updateQuestionPostTime, true),
NewMigration("add login limitations", addLoginLimitations, true),
}

View File

@ -0,0 +1,42 @@
package migrations
import (
"fmt"
"github.com/answerdev/answer/internal/entity"
"github.com/segmentfault/pacman/log"
"xorm.io/xorm"
)
func updateRolePinAndHideFeatures(x *xorm.Engine) error {
defaultConfigTable := []*entity.Config{
{ID: 119, Key: "question.pin", Value: `0`},
{ID: 120, Key: "question.unpin", Value: `0`},
{ID: 121, Key: "question.show", Value: `0`},
{ID: 122, Key: "question.hide", Value: `0`},
{ID: 123, Key: "rank.question.pin", Value: `-1`},
{ID: 124, Key: "rank.question.unpin", Value: `-1`},
{ID: 125, Key: "rank.question.show", Value: `-1`},
{ID: 126, Key: "rank.question.hide", Value: `-1`},
}
for _, c := range defaultConfigTable {
exist, err := x.Get(&entity.Config{ID: c.ID})
if err != nil {
return fmt.Errorf("get config failed: %w", err)
}
if exist {
if _, err = x.Update(c, &entity.Config{ID: c.ID}); 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 nil
}

View File

@ -0,0 +1,33 @@
package migrations
import (
"fmt"
"github.com/answerdev/answer/internal/entity"
"github.com/segmentfault/pacman/log"
"xorm.io/xorm"
)
func updateQuestionPostTime(x *xorm.Engine) error {
questionList := make([]entity.Question, 0)
err := x.Find(&questionList, &entity.Question{})
if err != nil {
return fmt.Errorf("get questions failed: %w", err)
}
for _, item := range questionList {
if item.PostUpdateTime.IsZero() {
if !item.UpdatedAt.IsZero() {
item.PostUpdateTime = item.UpdatedAt
} else if !item.CreatedAt.IsZero() {
item.PostUpdateTime = item.CreatedAt
}
if _, err = x.Update(item, &entity.Question{ID: item.ID}); err != nil {
log.Errorf("update %+v config failed: %s", item, err)
return fmt.Errorf("update question failed: %w", err)
}
}
}
return nil
}

View File

@ -18,10 +18,6 @@ func addPlugin(x *xorm.Engine) error {
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 {

View File

@ -63,18 +63,22 @@ func addRolePinAndHideFeatures(x *xorm.Engine) error {
}
defaultConfigTable := []*entity.Config{
{ID: 119, Key: "question.pin", Value: `-1`},
{ID: 120, Key: "question.unpin", Value: `-1`},
{ID: 121, Key: "question.show", Value: `-1`},
{ID: 122, Key: "question.hide", Value: `-1`},
{ID: 119, Key: "question.pin", Value: `0`},
{ID: 120, Key: "question.unpin", Value: `0`},
{ID: 121, Key: "question.show", Value: `0`},
{ID: 122, Key: "question.hide", Value: `0`},
{ID: 123, Key: "rank.question.pin", Value: `-1`},
{ID: 124, Key: "rank.question.unpin", Value: `-1`},
{ID: 125, Key: "rank.question.show", Value: `-1`},
{ID: 126, Key: "rank.question.hide", Value: `-1`},
}
for _, c := range defaultConfigTable {
exist, err := x.Get(&entity.Config{ID: c.ID, Key: c.Key})
exist, err := x.Get(&entity.Config{ID: c.ID})
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 {
if _, err = x.Update(c, &entity.Config{ID: c.ID}); err != nil {
log.Errorf("update %+v config failed: %s", c, err)
return fmt.Errorf("update config failed: %w", err)
}

View File

@ -202,7 +202,9 @@ func (ar *AnswerActivityRepo) AcceptAnswer(ctx context.Context,
msg.TriggerUserID = questionUserID
msg.ObjectType = constant.AnswerObjectType
}
notice_queue.AddNotification(msg)
if msg.TriggerUserID != msg.ReceiverUserID {
notice_queue.AddNotification(msg)
}
}
for _, act := range addActivityList {
@ -214,7 +216,7 @@ func (ar *AnswerActivityRepo) AcceptAnswer(ctx context.Context,
if act.UserID != questionUserID {
msg.TriggerUserID = questionUserID
msg.ObjectType = constant.AnswerObjectType
msg.NotificationAction = constant.AcceptAnswer
msg.NotificationAction = constant.NotificationAcceptAnswer
notice_queue.AddNotification(msg)
}
}

View File

@ -5,6 +5,7 @@ import (
"strings"
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/pkg/converter"
"github.com/answerdev/answer/internal/base/pager"
@ -70,7 +71,9 @@ var LimitDownActions = map[string][]string{
func (vr *VoteRepo) vote(ctx context.Context, objectID string, userID, objectUserID string, actions []string) (resp *schema.VoteResp, err error) {
resp = &schema.VoteResp{}
notificationUserIDs := make([]string, 0)
achievementNotificationUserIDs := make([]string, 0)
sendInboxNotification := false
upVote := false
_, err = vr.data.DB.Transaction(func(session *xorm.Session) (result any, err error) {
result = nil
for _, action := range actions {
@ -127,7 +130,7 @@ func (vr *VoteRepo) vote(ctx context.Context, objectID string, userID, objectUse
if isReachStandard {
insertActivity.Rank = 0
}
notificationUserIDs = append(notificationUserIDs, activityUserID)
achievementNotificationUserIDs = append(achievementNotificationUserIDs, activityUserID)
}
if has {
@ -142,13 +145,17 @@ func (vr *VoteRepo) vote(ctx context.Context, objectID string, userID, objectUse
if err != nil {
return nil, err
}
sendInboxNotification = true
}
// update votes
if action == "vote_down" || action == "vote_up" {
if action == constant.ActVoteDown || action == constant.ActVoteUp {
votes := 1
if action == "vote_down" {
if action == constant.ActVoteDown {
upVote = false
votes = -1
} else {
upVote = true
}
err = vr.updateVotes(ctx, session, objectID, votes)
if err != nil {
@ -165,9 +172,12 @@ func (vr *VoteRepo) vote(ctx context.Context, objectID string, userID, objectUse
resp, err = vr.GetVoteResultByObjectId(ctx, objectID)
resp.VoteStatus = vr.voteCommon.GetVoteStatus(ctx, objectID, userID)
for _, activityUserID := range notificationUserIDs {
for _, activityUserID := range achievementNotificationUserIDs {
vr.sendNotification(ctx, activityUserID, objectUserID, objectID)
}
if sendInboxNotification {
vr.sendVoteInboxNotification(userID, objectUserID, objectID, upVote)
}
return
}
@ -441,3 +451,40 @@ func (vr *VoteRepo) sendNotification(ctx context.Context, activityUserID, object
}
notice_queue.AddNotification(msg)
}
func (vr *VoteRepo) sendVoteInboxNotification(triggerUserID, receiverUserID, objectID string, upvote bool) {
if triggerUserID == receiverUserID {
return
}
objectType, _ := obj.GetObjectTypeStrByObjectID(objectID)
msg := &schema.NotificationMsg{
TriggerUserID: triggerUserID,
ReceiverUserID: receiverUserID,
Type: schema.NotificationTypeInbox,
ObjectID: objectID,
ObjectType: objectType,
}
if objectType == constant.QuestionObjectType {
if upvote {
msg.NotificationAction = constant.NotificationUpVotedTheQuestion
} else {
msg.NotificationAction = constant.NotificationDownVotedTheQuestion
}
}
if objectType == constant.AnswerObjectType {
if upvote {
msg.NotificationAction = constant.NotificationUpVotedTheAnswer
} else {
msg.NotificationAction = constant.NotificationDownVotedTheAnswer
}
}
if objectType == constant.CommentObjectType {
if upvote {
msg.NotificationAction = constant.NotificationUpVotedTheComment
}
}
if len(msg.NotificationAction) > 0 {
notice_queue.AddNotification(msg)
}
}

View File

@ -148,7 +148,7 @@ func (ar *authRepo) AddUserTokenMapping(ctx context.Context, userID, accessToken
}
// RemoveUserTokens Log out all users under this user id
func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string) {
func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string, remainToken string) {
key := constant.UserTokenMappingCacheKey + userID
resp, _ := ar.data.Cache.GetString(ctx, key)
mapping := make(map[string]bool, 0)
@ -158,6 +158,9 @@ func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string) {
}
for token := range mapping {
if token == remainToken {
continue
}
if err := ar.RemoveUserCacheInfo(ctx, token); err != nil {
log.Error(err)
} else {

View File

@ -104,26 +104,21 @@ func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagIDs
argsQ = append(argsQ, entity.QuestionStatusDeleted, entity.QuestionShow)
argsA = append(argsA, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow)
for i, word := range words {
if i == 0 {
b.Where(builder.Like{"title", word}).
Or(builder.Like{"original_text", word})
argsQ = append(argsQ, "%"+word+"%")
argsQ = append(argsQ, "%"+word+"%")
likeConQ := builder.NewCond()
likeConA := builder.NewCond()
for _, word := range words {
likeConQ = likeConQ.Or(builder.Like{"title", word}).
Or(builder.Like{"original_text", word})
argsQ = append(argsQ, "%"+word+"%")
argsQ = append(argsQ, "%"+word+"%")
ub.Where(builder.Like{"`answer`.original_text", word})
argsA = append(argsA, "%"+word+"%")
} else {
b.Or(builder.Like{"title", word}).
Or(builder.Like{"original_text", word})
argsQ = append(argsQ, "%"+word+"%")
argsQ = append(argsQ, "%"+word+"%")
ub.Or(builder.Like{"`answer`.original_text", word})
argsA = append(argsA, "%"+word+"%")
}
likeConA = likeConA.Or(builder.Like{"`answer`.original_text", word})
argsA = append(argsA, "%"+word+"%")
}
b.Where(likeConQ)
ub.Where(likeConA)
// check tag
if len(tagIDs) > 0 {
for ti, tagID := range tagIDs {
@ -234,17 +229,14 @@ func (sr *searchRepo) SearchQuestions(ctx context.Context, words []string, tagID
b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow})
args = append(args, entity.QuestionStatusDeleted, entity.QuestionShow)
for i, word := range words {
if i == 0 {
b.Where(builder.Like{"title", word}).
Or(builder.Like{"original_text", word})
args = append(args, "%"+word+"%")
args = append(args, "%"+word+"%")
} else {
b.Or(builder.Like{"original_text", word})
args = append(args, "%"+word+"%")
}
likeConQ := builder.NewCond()
for _, word := range words {
likeConQ = likeConQ.Or(builder.Like{"title", word}).
Or(builder.Like{"original_text", word})
args = append(args, "%"+word+"%")
args = append(args, "%"+word+"%")
}
b.Where(likeConQ)
// check tag
if len(tagIDs) > 0 {
@ -349,16 +341,14 @@ func (sr *searchRepo) SearchAnswers(ctx context.Context, words []string, tagIDs
And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow})
args = append(args, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow)
for i, word := range words {
if i == 0 {
b.Where(builder.Like{"`answer`.original_text", word})
args = append(args, "%"+word+"%")
} else {
b.Or(builder.Like{"`answer`.original_text", word})
args = append(args, "%"+word+"%")
}
likeConA := builder.NewCond()
for _, word := range words {
likeConA = likeConA.Or(builder.Like{"`answer`.original_text", word})
args = append(args, "%"+word+"%")
}
b.Where(likeConA)
// check tag
if len(tagIDs) > 0 {
for ti, tagID := range tagIDs {

View File

@ -46,7 +46,7 @@ func (tr *tagCommonRepo) GetTagListByIDs(ctx context.Context, ids []string) (tag
// GetTagBySlugName get tag by slug name
func (tr *tagCommonRepo) GetTagBySlugName(ctx context.Context, slugName string) (tagInfo *entity.Tag, exist bool, err error) {
tagInfo = &entity.Tag{}
session := tr.data.DB.Where("slug_name = LOWER(?)", slugName)
session := tr.data.DB.Where("LOWER(slug_name) = ?", slugName)
session.Where(builder.Eq{"status": entity.TagStatusAvailable})
exist, err = session.Get(tagInfo)
if err != nil {

View File

@ -86,6 +86,9 @@ func (ur *userAdminRepo) GetUserInfo(ctx context.Context, userID string) (user *
if err != nil {
return nil, false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
if !exist {
return
}
err = tryToDecorateUserInfoFromUserCenter(ctx, ur.data, user)
if err != nil {
return nil, false, err
@ -102,6 +105,9 @@ func (ur *userAdminRepo) GetUserInfoByEmail(ctx context.Context, email string) (
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
return
}
if !exist {
return
}
err = tryToDecorateUserInfoFromUserCenter(ctx, ur.data, user)
if err != nil {
return nil, false, err

View File

@ -196,6 +196,9 @@ func (ur *userRepo) GetUserCount(ctx context.Context) (count int64, err error) {
}
func tryToDecorateUserInfoFromUserCenter(ctx context.Context, data *data.Data, original *entity.User) (err error) {
if original == nil {
return nil
}
uc, ok := plugin.GetUserCenter()
if !ok {
return nil

View File

@ -125,14 +125,14 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
//answer
r.GET("/answer/info", a.answerController.Get)
r.GET("/answer/page", a.answerController.AnswerList)
r.GET("/personal/answer/page", a.questionController.UserAnswerList)
r.GET("/personal/answer/page", a.questionController.PersonalAnswerPage)
//question
r.GET("/question/info", a.questionController.GetQuestion)
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)
r.GET("/personal/question/page", a.questionController.PersonalQuestionPage)
// comment
r.GET("/comment/page", a.commentController.GetCommentWithPage)
@ -187,7 +187,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
// collection
r.POST("/collection/switch", a.collectionController.CollectionSwitch)
r.GET("/personal/collection/page", a.questionController.UserCollectionList)
r.GET("/personal/collection/page", a.questionController.PersonalCollectionPage)
// question
r.POST("/question", a.questionController.AddQuestion)
@ -248,8 +248,8 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) {
r.GET("/users/page", a.adminUserController.GetUserPage)
r.PUT("/user/status", a.adminUserController.UpdateUserStatus)
r.PUT("/user/role", a.adminUserController.UpdateUserRole)
r.POST("/user", middleware.BanAPIForUserCenter, a.adminUserController.AddUser)
r.PUT("/user/password", middleware.BanAPIForUserCenter, a.adminUserController.UpdateUserPassword)
r.POST("/user", a.adminUserController.AddUser)
r.PUT("/user/password", a.adminUserController.UpdateUserPassword)
// reason
r.GET("/reasons", a.reasonController.Reasons)

View File

@ -63,9 +63,23 @@ func (g *GetPluginConfigResp) SetConfigFields(ctx *gin.Context, fields []plugin.
configField.UIOptions.Label = field.UIOptions.Label.Translate(ctx)
configField.UIOptions.Text = field.UIOptions.Text.Translate(ctx)
if field.UIOptions.Action != nil {
configField.UIOptions.Action = &ConfigFieldUIOptionAction{
Url: field.UIOptions.Action.Url,
uiOptionAction := &UIOptionAction{
Url: field.UIOptions.Action.Url,
Method: field.UIOptions.Action.Method,
}
if field.UIOptions.Action.Loading != nil {
uiOptionAction.Loading = &LoadingAction{
Text: field.UIOptions.Action.Loading.Text.Translate(ctx),
State: string(field.UIOptions.Action.Loading.State),
}
}
if field.UIOptions.Action.OnComplete != nil {
uiOptionAction.OnCompleteAction = &OnCompleteAction{
ToastReturnMessage: field.UIOptions.Action.OnComplete.ToastReturnMessage,
RefreshFormConfig: field.UIOptions.Action.OnComplete.RefreshFormConfig,
}
}
configField.UIOptions.Action = uiOptionAction
}
for _, option := range field.Options {
@ -90,13 +104,13 @@ type ConfigField struct {
}
type ConfigFieldUIOptions struct {
Placeholder string `json:"placeholder,omitempty"`
Rows string `json:"rows,omitempty"`
InputType string `json:"input_type,omitempty"`
Label string `json:"label,omitempty"`
Action *ConfigFieldUIOptionAction `json:"action,omitempty"`
Variant string `json:"variant,omitempty"`
Text string `json:"text,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
Rows string `json:"rows,omitempty"`
InputType string `json:"input_type,omitempty"`
Label string `json:"label,omitempty"`
Action *UIOptionAction `json:"action,omitempty"`
Variant string `json:"variant,omitempty"`
Text string `json:"text,omitempty"`
}
type ConfigFieldOption struct {
@ -104,8 +118,21 @@ type ConfigFieldOption struct {
Value string `json:"value"`
}
type ConfigFieldUIOptionAction struct {
Url string `json:"url"`
type UIOptionAction struct {
Url string `json:"url"`
Method string `json:"method,omitempty"`
Loading *LoadingAction `json:"loading,omitempty"`
OnCompleteAction *OnCompleteAction `json:"on_complete,omitempty"`
}
type LoadingAction struct {
Text string `json:"text"`
State string `json:"state"`
}
type OnCompleteAction struct {
ToastReturnMessage bool `json:"toast_return_message"`
RefreshFormConfig bool `json:"refresh_form_config"`
}
type UpdatePluginConfigReq struct {

View File

@ -375,3 +375,25 @@ type SiteMapQuestionInfo struct {
Title string `json:"title"`
UpdateTime string `json:"time"`
}
type PersonalQuestionPageReq 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"`
Username string `validate:"omitempty,gt=0,lte=100" form:"username"`
LoginUserID string `json:"-"`
}
type PersonalAnswerPageReq 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"`
Username string `validate:"omitempty,gt=0,lte=100" form:"username"`
LoginUserID string `json:"-"`
}
type PersonalCollectionPageReq struct {
Page int `validate:"omitempty,min=1" form:"page"`
PageSize int `validate:"omitempty,min=1" form:"page_size"`
UserID string `json:"-"`
}

View File

@ -69,8 +69,10 @@ type UserCenterUserSettingsResp struct {
}
type UserCenterAdminFunctionAgentResp struct {
UserStatusAgentEnabled bool `json:"user_status_agent_enabled"`
UserPasswordAgentEnabled bool `json:"user_password_agent_enabled"`
AllowCreateUser bool `json:"allow_create_user"`
AllowUpdateUserStatus bool `json:"allow_update_user_status"`
AllowUpdateUserPassword bool `json:"allow_update_user_password"`
AllowUpdateUserRole bool `json:"allow_update_user_role"`
}
type UserSettingAgent struct {

View File

@ -4,14 +4,12 @@ import (
"encoding/json"
"github.com/answerdev/answer/internal/base/constant"
"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/answerdev/answer/pkg/gravatar"
"github.com/jinzhu/copier"
"github.com/segmentfault/pacman/errors"
)
// UserVerifyEmailReq user verify email request
@ -277,14 +275,14 @@ func (u *UserRegisterReq) Check() (errFields []*validator.FormErrorField, err er
return nil, nil
}
// UserModifyPassWordRequest
type UserModifyPassWordRequest struct {
type UserModifyPasswordReq struct {
OldPass string `validate:"omitempty,gte=8,lte=32" json:"old_pass"`
Pass string `validate:"required,gte=8,lte=32" json:"pass"`
UserID string `json:"-"`
UserID string `json:"-"`
AccessToken string `json:"-"`
}
func (u *UserModifyPassWordRequest) Check() (errFields []*validator.FormErrorField, err error) {
func (u *UserModifyPasswordReq) Check() (errFields []*validator.FormErrorField, err error) {
// TODO i18n
err = checker.CheckPassword(8, 32, 0, u.Pass)
if err != nil {
@ -300,7 +298,7 @@ func (u *UserModifyPassWordRequest) Check() (errFields []*validator.FormErrorFie
type UpdateInfoRequest struct {
// display_name
DisplayName string `validate:"required,gt=0,lte=30" json:"display_name"`
DisplayName string `validate:"omitempty,gt=0,lte=30" json:"display_name"`
// username
Username string `validate:"omitempty,gt=3,lte=30" json:"username"`
// avatar
@ -329,16 +327,6 @@ func (a *AvatarInfo) ToJsonString() string {
}
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: reason.UsernameInvalid,
}
errFields = append(errFields, errField)
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
}
req.BioHTML = converter.Markdown2BasicHTML(req.Bio)
return nil, nil
}
@ -421,6 +409,7 @@ type GetOtherUserInfoResp struct {
type UserChangeEmailSendCodeReq struct {
UserVerifyEmailSendReq
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail"`
Pass string `validate:"omitempty,gte=8,lte=32" json:"pass"`
UserID string `json:"-"`
}

View File

@ -476,7 +476,7 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.A
msg.ReceiverUserID = answerInfo.UserID
msg.TriggerUserID = answerInfo.UserID
msg.ObjectType = constant.AnswerObjectType
msg.NotificationAction = constant.YourAnswerWasDeleted
msg.NotificationAction = constant.NotificationYourAnswerWasDeleted
notice_queue.AddNotification(msg)
return nil
@ -566,7 +566,7 @@ func (as *AnswerService) notificationUpdateAnswer(ctx context.Context, questionU
ObjectID: answerID,
}
msg.ObjectType = constant.AnswerObjectType
msg.NotificationAction = constant.UpdateAnswer
msg.NotificationAction = constant.NotificationUpdateAnswer
notice_queue.AddNotification(msg)
}
@ -583,7 +583,7 @@ func (as *AnswerService) notificationAnswerTheQuestion(ctx context.Context,
ObjectID: answerID,
}
msg.ObjectType = constant.AnswerObjectType
msg.NotificationAction = constant.AnswerTheQuestion
msg.NotificationAction = constant.NotificationAnswerTheQuestion
notice_queue.AddNotification(msg)
userInfo, exist, err := as.userRepo.GetByUserID(ctx, questionUserID)

View File

@ -21,7 +21,7 @@ type AuthRepo interface {
SetAdminUserCacheInfo(ctx context.Context, accessToken string, userInfo *entity.UserCacheInfo) error
RemoveAdminUserCacheInfo(ctx context.Context, accessToken string) (err error)
AddUserTokenMapping(ctx context.Context, userID, accessToken string) (err error)
RemoveUserTokens(ctx context.Context, userID string)
RemoveUserTokens(ctx context.Context, userID string, remainToken string)
}
// AuthService kit service
@ -43,7 +43,6 @@ func (as *AuthService) GetUserCacheInfo(ctx context.Context, accessToken string)
}
cacheInfo, _ := as.authRepo.GetUserStatus(ctx, userCacheInfo.UserID)
if cacheInfo != nil {
log.Debugf("user status updated: %+v", cacheInfo)
userCacheInfo.UserStatus = cacheInfo.UserStatus
userCacheInfo.EmailStatus = cacheInfo.EmailStatus
userCacheInfo.RoleID = cacheInfo.RoleID
@ -94,9 +93,14 @@ func (as *AuthService) AddUserTokenMapping(ctx context.Context, userID, accessTo
return as.authRepo.AddUserTokenMapping(ctx, userID, accessToken)
}
// RemoveUserTokens Log out all users under this user id
func (as *AuthService) RemoveUserTokens(ctx context.Context, userID string) {
as.authRepo.RemoveUserTokens(ctx, userID)
// RemoveUserAllTokens Log out all users under this user id
func (as *AuthService) RemoveUserAllTokens(ctx context.Context, userID string) {
as.authRepo.RemoveUserTokens(ctx, userID, "")
}
// RemoveTokensExceptCurrentUser remove all tokens except the current user
func (as *AuthService) RemoveTokensExceptCurrentUser(ctx context.Context, userID string, accessToken string) {
as.authRepo.RemoveUserTokens(ctx, userID, accessToken)
}
//Admin

View File

@ -471,7 +471,7 @@ func (cs *CommentService) notificationQuestionComment(ctx context.Context, quest
ObjectID: commentID,
}
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.CommentQuestion
msg.NotificationAction = constant.NotificationCommentQuestion
notice_queue.AddNotification(msg)
receiverUserInfo, exist, err := cs.userRepo.GetByUserID(ctx, questionUserID)
@ -526,7 +526,7 @@ func (cs *CommentService) notificationAnswerComment(ctx context.Context,
ObjectID: commentID,
}
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.CommentAnswer
msg.NotificationAction = constant.NotificationCommentAnswer
notice_queue.AddNotification(msg)
receiverUserInfo, exist, err := cs.userRepo.GetByUserID(ctx, answerUserID)
@ -578,7 +578,7 @@ func (cs *CommentService) notificationCommentReply(ctx context.Context, replyUse
ObjectID: commentID,
}
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.ReplyToYou
msg.NotificationAction = constant.NotificationReplyToYou
notice_queue.AddNotification(msg)
}
@ -599,7 +599,7 @@ func (cs *CommentService) notificationMention(
ObjectID: commentID,
}
msg.ObjectType = constant.CommentObjectType
msg.NotificationAction = constant.MentionYou
msg.NotificationAction = constant.NotificationMentionYou
notice_queue.AddNotification(msg)
alreadyNotifiedUserIDs = append(alreadyNotifiedUserIDs, userInfo.ID)
}

View File

@ -7,14 +7,15 @@ import (
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/pager"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema"
notficationcommon "github.com/answerdev/answer/internal/service/notification_common"
"github.com/answerdev/answer/internal/service/revision_common"
"github.com/answerdev/answer/pkg/uid"
"github.com/jinzhu/copier"
"github.com/segmentfault/pacman/i18n"
"github.com/segmentfault/pacman/log"
)
@ -127,35 +128,47 @@ func (ns *NotificationService) GetNotificationPage(ctx context.Context, searchCo
if err != nil {
return nil, err
}
resp, err = ns.formatNotificationPage(ctx, notifications)
if err != nil {
return nil, err
}
return pager.NewPageModel(total, resp), nil
}
func (ns *NotificationService) formatNotificationPage(ctx context.Context, notifications []*entity.Notification) (
resp []*schema.NotificationContent, err error) {
lang := handler.GetLangByCtx(ctx)
for _, notificationInfo := range notifications {
item := &schema.NotificationContent{}
err := json.Unmarshal([]byte(notificationInfo.Content), item)
if err != nil {
if err := json.Unmarshal([]byte(notificationInfo.Content), item); err != nil {
log.Error("NotificationContent Unmarshal Error", err.Error())
continue
}
lang, _ := ctx.Value(constant.AcceptLanguageFlag).(i18n.Language)
item.NotificationAction = translator.Tr(lang, item.NotificationAction)
item.ID = notificationInfo.ID
item.UpdateTime = notificationInfo.UpdatedAt.Unix()
if notificationInfo.IsRead == schema.NotificationRead {
item.IsRead = true
// If notification is downvote, the user info is not needed.
if item.NotificationAction == constant.NotificationDownVotedTheQuestion ||
item.NotificationAction == constant.NotificationDownVotedTheAnswer {
item.UserInfo = nil
}
answerID, ok := item.ObjectInfo.ObjectMap["answer"]
if ok {
item.ID = notificationInfo.ID
item.NotificationAction = translator.Tr(lang, item.NotificationAction)
item.UpdateTime = notificationInfo.UpdatedAt.Unix()
item.IsRead = notificationInfo.IsRead == schema.NotificationRead
if answerID, ok := item.ObjectInfo.ObjectMap["answer"]; ok {
if item.ObjectInfo.ObjectID == answerID {
item.ObjectInfo.ObjectID = uid.EnShortID(item.ObjectInfo.ObjectMap["answer"])
}
item.ObjectInfo.ObjectMap["answer"] = uid.EnShortID(item.ObjectInfo.ObjectMap["answer"])
}
questionID, ok := item.ObjectInfo.ObjectMap["question"]
if ok {
if questionID, ok := item.ObjectInfo.ObjectMap["question"]; ok {
if item.ObjectInfo.ObjectID == questionID {
item.ObjectInfo.ObjectID = uid.EnShortID(item.ObjectInfo.ObjectMap["question"])
}
item.ObjectInfo.ObjectMap["question"] = uid.EnShortID(item.ObjectInfo.ObjectMap["question"])
}
resp = append(resp, item)
}
return pager.NewPageModel(total, resp), nil
return resp, nil
}

View File

@ -193,10 +193,10 @@ func (ns *NotificationCommon) SendNotificationToAllFollower(ctx context.Context,
if msg.NoNeedPushAllFollow {
return
}
if msg.NotificationAction != constant.UpdateQuestion &&
msg.NotificationAction != constant.AnswerTheQuestion &&
msg.NotificationAction != constant.UpdateAnswer &&
msg.NotificationAction != constant.AcceptAnswer {
if msg.NotificationAction != constant.NotificationUpdateQuestion &&
msg.NotificationAction != constant.NotificationAnswerTheQuestion &&
msg.NotificationAction != constant.NotificationUpdateAnswer &&
msg.NotificationAction != constant.NotificationAcceptAnswer {
return
}
condObjectID := msg.ObjectID

View File

@ -10,10 +10,10 @@ const (
QuestionReopen = "question.reopen"
QuestionVoteUp = "question.vote_up"
QuestionVoteDown = "question.vote_down"
QuestionPin = "question.pin" //Top the question
QuestionUnPin = "question.unpin" //untop the question
QuestionHide = "question.hide" //hide the question
QuestionShow = "question.show" //show the question
QuestionPin = "question.pin"
QuestionUnPin = "question.unpin"
QuestionHide = "question.hide"
QuestionShow = "question.show"
AnswerAdd = "answer.add"
AnswerEdit = "answer.edit"
AnswerEditWithoutReview = "answer.edit_without_review"

View File

@ -10,6 +10,7 @@ import (
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/base/data"
"github.com/answerdev/answer/internal/base/handler"
"github.com/answerdev/answer/internal/base/pager"
"github.com/answerdev/answer/internal/base/reason"
"github.com/answerdev/answer/internal/base/translator"
"github.com/answerdev/answer/internal/base/validator"
@ -270,6 +271,7 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question
question.Status = entity.QuestionStatusAvailable
question.RevisionID = "0"
question.CreatedAt = now
question.PostUpdateTime = now
question.Pin = entity.QuestionUnPin
question.Show = entity.QuestionShow
//question.UpdatedAt = nil
@ -733,70 +735,74 @@ func (qs *QuestionService) CheckChangeReservedTag(ctx context.Context, oldobject
return qs.tagCommon.CheckChangeReservedTag(ctx, oldobjectTagData, objectTagData)
}
func (qs *QuestionService) SearchUserList(ctx context.Context, userName, order string, page, pageSize int, loginUserID string) ([]*schema.UserQuestionInfo, int64, error) {
userlist := make([]*schema.UserQuestionInfo, 0)
// PersonalQuestionPage get question list by user
func (qs *QuestionService) PersonalQuestionPage(ctx context.Context, req *schema.PersonalQuestionPageReq) (
pageModel *pager.PageModel, err error) {
userinfo, Exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, userName)
userinfo, exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, req.Username)
if err != nil {
return userlist, 0, err
return nil, err
}
if !Exist {
return userlist, 0, nil
if !exist {
return nil, errors.BadRequest(reason.UserNotFound)
}
search := &schema.QuestionPageReq{}
search.OrderCond = order
search.Page = page
search.PageSize = pageSize
search.OrderCond = req.OrderCond
search.Page = req.Page
search.PageSize = req.PageSize
search.UserIDBeSearched = userinfo.ID
search.LoginUserID = loginUserID
questionlist, count, err := qs.GetQuestionPage(ctx, search)
search.LoginUserID = req.LoginUserID
questionList, total, err := qs.GetQuestionPage(ctx, search)
if err != nil {
return userlist, 0, err
return nil, err
}
for _, item := range questionlist {
userQuestionInfoList := make([]*schema.UserQuestionInfo, 0)
for _, item := range questionList {
info := &schema.UserQuestionInfo{}
_ = copier.Copy(info, item)
status, ok := entity.AdminQuestionSearchStatusIntToString[item.Status]
if ok {
info.Status = status
}
userlist = append(userlist, info)
userQuestionInfoList = append(userQuestionInfoList, info)
}
return userlist, count, nil
return pager.NewPageModel(total, userQuestionInfoList), nil
}
func (qs *QuestionService) SearchUserAnswerList(ctx context.Context, userName, order string, page, pageSize int, loginUserID string) ([]*schema.UserAnswerInfo, int64, error) {
answerlist := make([]*schema.AnswerInfo, 0)
userAnswerlist := make([]*schema.UserAnswerInfo, 0)
userinfo, Exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, userName)
func (qs *QuestionService) PersonalAnswerPage(ctx context.Context, req *schema.PersonalAnswerPageReq) (
pageModel *pager.PageModel, err error) {
userinfo, exist, err := qs.userCommon.GetUserBasicInfoByUserName(ctx, req.Username)
if err != nil {
return userAnswerlist, 0, err
return nil, err
}
if !Exist {
return userAnswerlist, 0, nil
if !exist {
return nil, errors.BadRequest(reason.UserNotFound)
}
answersearch := &entity.AnswerSearch{}
answersearch.UserID = userinfo.ID
answersearch.PageSize = pageSize
answersearch.Page = page
if order == "newest" {
answersearch.PageSize = req.PageSize
answersearch.Page = req.Page
if req.OrderCond == "newest" {
answersearch.Order = entity.AnswerSearchOrderByTime
} else {
answersearch.Order = entity.AnswerSearchOrderByDefault
}
questionIDs := make([]string, 0)
answerList, count, err := qs.questioncommon.AnswerCommon.Search(ctx, answersearch)
answerList, total, err := qs.questioncommon.AnswerCommon.Search(ctx, answersearch)
if err != nil {
return userAnswerlist, count, err
return nil, err
}
answerlist := make([]*schema.AnswerInfo, 0)
userAnswerlist := make([]*schema.UserAnswerInfo, 0)
for _, item := range answerList {
answerinfo := qs.questioncommon.AnswerCommon.ShowFormat(ctx, item)
answerlist = append(answerlist, answerinfo)
questionIDs = append(questionIDs, uid.DeShortID(item.QuestionID))
}
questionMaps, err := qs.questioncommon.FindInfoByID(ctx, questionIDs, loginUserID)
questionMaps, err := qs.questioncommon.FindInfoByID(ctx, questionIDs, req.LoginUserID)
if err != nil {
return userAnswerlist, count, err
return nil, err
}
for _, item := range answerlist {
@ -813,34 +819,29 @@ func (qs *QuestionService) SearchUserAnswerList(ctx context.Context, userName, o
}
}
return userAnswerlist, count, nil
return pager.NewPageModel(total, userAnswerlist), nil
}
func (qs *QuestionService) SearchUserCollectionList(ctx context.Context, page, pageSize int, loginUserID string) ([]*schema.QuestionInfo, int64, error) {
// PersonalCollectionPage get collection list by user
func (qs *QuestionService) PersonalCollectionPage(ctx context.Context, req *schema.PersonalCollectionPageReq) (
pageModel *pager.PageModel, err error) {
list := make([]*schema.QuestionInfo, 0)
userinfo, Exist, err := qs.userCommon.GetUserBasicInfoByID(ctx, loginUserID)
if err != nil {
return list, 0, err
}
if !Exist {
return list, 0, nil
}
collectionSearch := &entity.CollectionSearch{}
collectionSearch.UserID = userinfo.ID
collectionSearch.Page = page
collectionSearch.PageSize = pageSize
collectionlist, count, err := qs.collectionCommon.SearchList(ctx, collectionSearch)
collectionSearch.UserID = req.UserID
collectionSearch.Page = req.Page
collectionSearch.PageSize = req.PageSize
collectionList, total, err := qs.collectionCommon.SearchList(ctx, collectionSearch)
if err != nil {
return list, 0, err
return nil, err
}
questionIDs := make([]string, 0)
for _, item := range collectionlist {
for _, item := range collectionList {
questionIDs = append(questionIDs, item.ObjectID)
}
questionMaps, err := qs.questioncommon.FindInfoByID(ctx, questionIDs, loginUserID)
questionMaps, err := qs.questioncommon.FindInfoByID(ctx, questionIDs, req.UserID)
if err != nil {
return list, count, err
return nil, err
}
for _, id := range questionIDs {
_, ok := questionMaps[uid.EnShortID(id)]
@ -853,7 +854,7 @@ func (qs *QuestionService) SearchUserCollectionList(ctx context.Context, page, p
}
}
return list, count, nil
return pager.NewPageModel(total, list), nil
}
func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName string, loginUserID string) ([]*schema.UserQuestionInfo, []*schema.UserAnswerInfo, error) {
@ -1063,7 +1064,7 @@ func (qs *QuestionService) AdminSetQuestionStatus(ctx context.Context, questionI
msg.ReceiverUserID = questionInfo.UserID
msg.TriggerUserID = questionInfo.UserID
msg.ObjectType = constant.QuestionObjectType
msg.NotificationAction = constant.YourQuestionWasDeleted
msg.NotificationAction = constant.NotificationYourQuestionWasDeleted
notice_queue.AddNotification(msg)
return nil
}

View File

@ -66,7 +66,7 @@ func (rh *ReportHandle) HandleObject(ctx context.Context, reported *entity.Repor
switch req.FlaggedType {
case reasonDelete:
err = rh.commentRepo.RemoveComment(ctx, objectID)
rh.sendNotification(ctx, reportedUserID, objectID, constant.YourCommentWasDeleted)
rh.sendNotification(ctx, reportedUserID, objectID, constant.NotificationYourCommentWasDeleted)
}
}
return

View File

@ -209,7 +209,7 @@ func (rs *RevisionService) revisionAuditAnswer(ctx context.Context, revisionitem
ObjectID: answerinfo.ID,
}
msg.ObjectType = constant.AnswerObjectType
msg.NotificationAction = constant.UpdateAnswer
msg.NotificationAction = constant.NotificationUpdateAnswer
notice_queue.AddNotification(msg)
activity_queue.AddActivity(&schema.ActivityMsg{

View File

@ -36,12 +36,15 @@ func NewSiteInfoService(
tagCommonService *tagcommon.TagCommonService,
configRepo config.ConfigRepo,
) *SiteInfoService {
resp, err := siteInfoCommonService.GetSiteUsers(context.Background())
if err != nil {
log.Error(err)
} else {
constant.DefaultAvatar = resp.DefaultAvatar
usersSiteInfo, _ := siteInfoCommonService.GetSiteUsers(context.Background())
if usersSiteInfo != nil {
constant.DefaultAvatar = usersSiteInfo.DefaultAvatar
}
generalSiteInfo, _ := siteInfoCommonService.GetSiteGeneral(context.Background())
if generalSiteInfo != nil {
constant.DefaultSiteURL = generalSiteInfo.SiteUrl
}
return &SiteInfoService{
siteInfoRepo: siteInfoRepo,
siteInfoCommonService: siteInfoCommonService,
@ -116,18 +119,16 @@ func (s *SiteInfoService) GetSiteTheme(ctx context.Context) (resp *schema.SiteTh
func (s *SiteInfoService) SaveSiteGeneral(ctx context.Context, req schema.SiteGeneralReq) (err error) {
req.FormatSiteUrl()
var (
siteType = "general"
content []byte
)
content, _ = json.Marshal(req)
data := entity.SiteInfo{
Type: siteType,
content, _ := json.Marshal(req)
data := &entity.SiteInfo{
Type: constant.SiteTypeGeneral,
Content: string(content),
Status: 1,
}
err = s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeGeneral, data)
if err == nil {
constant.DefaultSiteURL = req.SiteUrl
}
err = s.siteInfoRepo.SaveByType(ctx, siteType, &data)
return
}
@ -267,8 +268,11 @@ func (s *SiteInfoService) UpdateSMTPConfig(ctx context.Context, req *schema.Upda
return
}
func (s *SiteInfoService) GetSeo(ctx context.Context) (resp *schema.SiteSeoResp, err error) {
resp = &schema.SiteSeoResp{}
func (s *SiteInfoService) GetSeo(ctx context.Context) (resp *schema.SiteSeoReq, err error) {
resp = &schema.SiteSeoReq{}
if err = s.siteInfoCommonService.GetSiteInfoByType(ctx, constant.SiteTypeSeo, resp); err != nil {
return resp, err
}
loginConfig, err := s.GetSiteLogin(ctx)
if err != nil {
log.Error(err)
@ -279,17 +283,6 @@ func (s *SiteInfoService) GetSeo(ctx context.Context) (resp *schema.SiteSeoResp,
resp.Robots = "User-agent: *\nDisallow: /"
return resp, nil
}
resp = &schema.SiteSeoResp{}
siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeSeo)
if err != nil {
log.Error(err)
return resp, nil
}
if !exist {
return resp, nil
}
_ = json.Unmarshal([]byte(siteInfo.Content), resp)
return resp, nil
}

View File

@ -116,7 +116,7 @@ func (us *UserAdminService) UpdateUserRole(ctx context.Context, req *schema.Upda
return err
}
us.authService.RemoveUserTokens(ctx, req.UserID)
us.authService.RemoveUserAllTokens(ctx, req.UserID)
return
}
@ -179,7 +179,7 @@ func (us *UserAdminService) UpdateUserPassword(ctx context.Context, req *schema.
return err
}
// logout this user
us.authService.RemoveUserTokens(ctx, req.UserID)
us.authService.RemoveUserAllTokens(ctx, req.UserID)
return
}

View File

@ -204,19 +204,28 @@ func (us *UserCenterLoginService) UserCenterUserSettings(ctx context.Context, us
return resp, nil
}
// UserCenterAdminFunctionAgent Check in the backend administration interface if the user-related functions
// are turned off due to turning on the User Center plugin.
func (us *UserCenterLoginService) UserCenterAdminFunctionAgent(ctx context.Context) (
resp *schema.UserCenterAdminFunctionAgentResp, err error) {
resp = &schema.UserCenterAdminFunctionAgentResp{}
resp = &schema.UserCenterAdminFunctionAgentResp{
AllowCreateUser: true,
AllowUpdateUserStatus: true,
AllowUpdateUserPassword: true,
AllowUpdateUserRole: true,
}
userCenter, ok := plugin.GetUserCenter()
if !ok {
return
}
desc := userCenter.Description()
// If user status agent is enabled, admin can not update user status in answer.
resp.UserStatusAgentEnabled = desc.UserStatusAgentEnabled
// If original user system is enabled, admin can update user password in answer.
// So user password agent is disabled.
resp.UserPasswordAgentEnabled = !desc.EnabledOriginalUserSystem
resp.AllowUpdateUserStatus = !desc.UserStatusAgentEnabled
// If original user system is enabled, admin can update user password and role in answer.
resp.AllowUpdateUserPassword = desc.EnabledOriginalUserSystem
resp.AllowUpdateUserRole = desc.EnabledOriginalUserSystem
resp.AllowCreateUser = desc.EnabledOriginalUserSystem
return resp, nil
}

View File

@ -15,6 +15,7 @@ import (
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/answerdev/answer/pkg/random"
"github.com/answerdev/answer/pkg/token"
"github.com/answerdev/answer/plugin"
"github.com/google/uuid"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
@ -318,3 +319,33 @@ func (us *UserExternalLoginService) ExternalLoginUnbinding(
return nil, us.userExternalLoginRepo.DeleteUserExternalLogin(ctx, req.UserID, req.ExternalID)
}
// CheckUserStatusInUserCenter check user status in user center
func (us *UserExternalLoginService) CheckUserStatusInUserCenter(ctx context.Context, userID string) (
valid bool, externalID string, err error) {
// If enable user center plugin, user status should be checked by user center
userCenter, ok := plugin.GetUserCenter()
if !ok {
return true, "", nil
}
userInfoList, err := us.GetExternalLoginUserInfoList(ctx, userID)
if err != nil {
return false, "", err
}
var thisUcUserInfo *entity.UserExternalLogin
for _, t := range userInfoList {
if t.Provider == userCenter.Info().SlugName {
thisUcUserInfo = t
break
}
}
// If this user not login by user center, no need to check user status
if thisUcUserInfo == nil {
return true, "", nil
}
userStatus := userCenter.UserStatus(thisUcUserInfo.ExternalID)
if userStatus == plugin.UserStatusDeleted {
return false, thisUcUserInfo.ExternalID, nil
}
return true, thisUcUserInfo.ExternalID, nil
}

View File

@ -80,6 +80,9 @@ func (us *UserService) GetUserInfoByUserID(ctx context.Context, token, userID st
if !exist {
return nil, errors.BadRequest(reason.UserNotFound)
}
if userInfo.Status == entity.UserStatusDeleted {
return nil, errors.Unauthorized(reason.UnauthorizedError)
}
roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID)
if err != nil {
log.Error(err)
@ -119,10 +122,17 @@ func (us *UserService) EmailLogin(ctx context.Context, req *schema.UserEmailLogi
if !us.verifyPassword(ctx, req.Pass, userInfo.Pass) {
return nil, errors.BadRequest(reason.EmailOrPasswordWrong)
}
ok, externalID, err := us.userExternalLoginService.CheckUserStatusInUserCenter(ctx, userInfo.ID)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.BadRequest(reason.EmailOrPasswordWrong)
}
err = us.userRepo.UpdateLastLoginDate(ctx, userInfo.ID)
if err != nil {
log.Error("UpdateLastLoginDate", err.Error())
log.Errorf("update last login data failed, err: %v", err)
}
roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID)
@ -137,6 +147,7 @@ func (us *UserService) EmailLogin(ctx context.Context, req *schema.UserEmailLogi
EmailStatus: userInfo.MailStatus,
UserStatus: userInfo.Status,
RoleID: roleID,
ExternalID: externalID,
}
resp.AccessToken, err = us.authService.SetUserCacheInfo(ctx, userCacheInfo)
if err != nil {
@ -178,42 +189,43 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet
return nil
}
// UseRePassword
func (us *UserService) UseRePassword(ctx context.Context, req *schema.UserRePassWordRequest) (resp *schema.GetUserResp, err error) {
// UpdatePasswordWhenForgot update user password when user forgot password
func (us *UserService) UpdatePasswordWhenForgot(ctx context.Context, req *schema.UserRePassWordRequest) (err error) {
data := &schema.EmailCodeContent{}
err = data.FromJSONString(req.Content)
if err != nil {
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
return errors.BadRequest(reason.EmailVerifyURLExpired)
}
userInfo, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
if err != nil {
return nil, err
return err
}
if !exist {
return nil, errors.BadRequest(reason.UserNotFound)
return errors.BadRequest(reason.UserNotFound)
}
enpass, err := us.encryptPassword(ctx, req.Pass)
if err != nil {
return nil, err
return err
}
err = us.userRepo.UpdatePass(ctx, userInfo.ID, enpass)
if err != nil {
return nil, err
return err
}
resp = &schema.GetUserResp{}
return resp, nil
// When the user changes the password, all the current user's tokens are invalid.
us.authService.RemoveUserAllTokens(ctx, userInfo.ID)
return nil
}
func (us *UserService) UserModifyPassWordVerification(ctx context.Context, request *schema.UserModifyPassWordRequest) (bool, error) {
userInfo, has, err := us.userRepo.GetByUserID(ctx, request.UserID)
func (us *UserService) UserModifyPassWordVerification(ctx context.Context, req *schema.UserModifyPasswordReq) (bool, error) {
userInfo, has, err := us.userRepo.GetByUserID(ctx, req.UserID)
if err != nil {
return false, err
}
if !has {
return false, fmt.Errorf("user does not exist")
return false, errors.BadRequest(reason.UserNotFound)
}
isPass := us.verifyPassword(ctx, request.OldPass, userInfo.Pass)
isPass := us.verifyPassword(ctx, req.OldPass, userInfo.Pass)
if !isPass {
return false, nil
}
@ -222,26 +234,29 @@ func (us *UserService) UserModifyPassWordVerification(ctx context.Context, reque
}
// UserModifyPassword user modify password
func (us *UserService) UserModifyPassword(ctx context.Context, request *schema.UserModifyPassWordRequest) error {
enpass, err := us.encryptPassword(ctx, request.Pass)
func (us *UserService) UserModifyPassword(ctx context.Context, req *schema.UserModifyPasswordReq) error {
enpass, err := us.encryptPassword(ctx, req.Pass)
if err != nil {
return err
}
userInfo, has, err := us.userRepo.GetByUserID(ctx, request.UserID)
userInfo, exist, err := us.userRepo.GetByUserID(ctx, req.UserID)
if err != nil {
return err
}
if !has {
return fmt.Errorf("user does not exist")
if !exist {
return errors.BadRequest(reason.UserNotFound)
}
isPass := us.verifyPassword(ctx, request.OldPass, userInfo.Pass)
isPass := us.verifyPassword(ctx, req.OldPass, userInfo.Pass)
if !isPass {
return fmt.Errorf("the old password verification failed")
return errors.BadRequest(reason.OldPasswordVerificationFailed)
}
err = us.userRepo.UpdatePass(ctx, userInfo.ID, enpass)
if err != nil {
return err
}
us.authService.RemoveTokensExceptCurrentUser(ctx, userInfo.ID, req.AccessToken)
return nil
}
@ -252,15 +267,23 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq
if err != nil {
return nil, err
}
oldUserInfo, exist, err := us.userRepo.GetByUserID(ctx, req.UserID)
if err != nil {
return nil, err
}
if !exist {
return nil, errors.BadRequest(reason.UserNotFound)
}
if len(req.Username) > 0 {
if siteUsers.AllowUpdateUsername && len(req.Username) > 0 {
if checker.IsInvalidUsername(req.Username) {
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "username",
ErrorMsg: reason.UsernameInvalid,
})
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
if checker.IsReservedUsername(req.Username) {
errFields = append(errFields, &validator.FormErrorField{
ErrorField: "username",
ErrorMsg: reason.UsernameInvalid,
})
return errFields, errors.BadRequest(reason.UsernameInvalid)
}
userInfo, exist, err := us.userRepo.GetByUsername(ctx, req.Username)
if err != nil {
return nil, err
@ -272,13 +295,14 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq
})
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)
}
}
oldUserInfo, exist, err := us.userRepo.GetByUserID(ctx, req.UserID)
if err != nil {
return nil, err
}
if !exist {
return nil, errors.BadRequest(reason.UserNotFound)
}
cond := us.formatUserInfoForUpdateInfo(oldUserInfo, req, siteUsers)
@ -300,13 +324,13 @@ func (us *UserService) formatUserInfoForUpdateInfo(
userInfo.Location = oldUserInfo.Location
userInfo.ID = req.UserID
if siteUsersConf.AllowUpdateDisplayName {
if len(req.DisplayName) > 0 && siteUsersConf.AllowUpdateDisplayName {
userInfo.DisplayName = req.DisplayName
}
if siteUsersConf.AllowUpdateUsername {
if len(req.Username) > 0 && siteUsersConf.AllowUpdateUsername {
userInfo.Username = req.Username
}
if siteUsersConf.AllowUpdateAvatar {
if len(avatar) > 0 && siteUsersConf.AllowUpdateAvatar {
userInfo.Avatar = string(avatar)
}
if siteUsersConf.AllowUpdateBio {
@ -543,6 +567,15 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
return nil, errors.BadRequest(reason.UserNotFound)
}
// If user's email already verified, then must verify password first.
if userInfo.MailStatus == entity.EmailStatusAvailable && !us.verifyPassword(ctx, req.Pass, userInfo.Pass) {
resp = append(resp, &validator.FormErrorField{
ErrorField: "pass",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.OldPasswordVerificationFailed),
})
return resp, errors.BadRequest(reason.OldPasswordVerificationFailed)
}
_, exist, err = us.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
return nil, err

View File

@ -63,12 +63,12 @@ func NewVoteService(
}
// VoteUp vote up
func (as *VoteService) VoteUp(ctx context.Context, dto *schema.VoteDTO) (voteResp *schema.VoteResp, err error) {
func (vs *VoteService) VoteUp(ctx context.Context, dto *schema.VoteDTO) (voteResp *schema.VoteResp, err error) {
voteResp = &schema.VoteResp{}
var objectUserID string
objectUserID, err = as.GetObjectUserID(ctx, dto.ObjectID)
objectUserID, err = vs.GetObjectUserID(ctx, dto.ObjectID)
if err != nil {
return
}
@ -80,19 +80,19 @@ func (as *VoteService) VoteUp(ctx context.Context, dto *schema.VoteDTO) (voteRes
}
if dto.IsCancel {
return as.voteRepo.VoteUpCancel(ctx, dto.ObjectID, dto.UserID, objectUserID)
return vs.voteRepo.VoteUpCancel(ctx, dto.ObjectID, dto.UserID, objectUserID)
} else {
return as.voteRepo.VoteUp(ctx, dto.ObjectID, dto.UserID, objectUserID)
return vs.voteRepo.VoteUp(ctx, dto.ObjectID, dto.UserID, objectUserID)
}
}
// VoteDown vote down
func (as *VoteService) VoteDown(ctx context.Context, dto *schema.VoteDTO) (voteResp *schema.VoteResp, err error) {
func (vs *VoteService) VoteDown(ctx context.Context, dto *schema.VoteDTO) (voteResp *schema.VoteResp, err error) {
voteResp = &schema.VoteResp{}
var objectUserID string
objectUserID, err = as.GetObjectUserID(ctx, dto.ObjectID)
objectUserID, err = vs.GetObjectUserID(ctx, dto.ObjectID)
if err != nil {
return
}
@ -104,9 +104,9 @@ func (as *VoteService) VoteDown(ctx context.Context, dto *schema.VoteDTO) (voteR
}
if dto.IsCancel {
return as.voteRepo.VoteDownCancel(ctx, dto.ObjectID, dto.UserID, objectUserID)
return vs.voteRepo.VoteDownCancel(ctx, dto.ObjectID, dto.UserID, objectUserID)
} else {
return as.voteRepo.VoteDown(ctx, dto.ObjectID, dto.UserID, objectUserID)
return vs.voteRepo.VoteDown(ctx, dto.ObjectID, dto.UserID, objectUserID)
}
}

View File

@ -38,6 +38,7 @@ func Markdown2HTML(source string) string {
filter.RequireNoFollowOnLinks(false)
filter.RequireParseableURLs(false)
filter.RequireNoFollowOnFullyQualifiedLinks(false)
filter.AllowElements("kbd")
html = filter.Sanitize(html)
return html
}
@ -46,7 +47,7 @@ func Markdown2HTML(source string) string {
func Markdown2BasicHTML(source string) string {
content := Markdown2HTML(source)
filter := bluemonday.NewPolicy()
filter.AllowElements("p", "b", "br")
filter.AllowElements("p", "b", "br", "strong", "em")
filter.AllowAttrs("src").OnElements("img")
filter.AddSpaceWhenStrippingTag(true)
content = filter.Sanitize(content)
@ -87,7 +88,11 @@ func (r *DangerousHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, n
l := n.Segments.Len()
for i := 0; i < l; i++ {
segment := n.Segments.At(i)
_, _ = w.Write(r.Filter.SanitizeBytes(segment.Value(source)))
if string(source[segment.Start:segment.Stop]) == "<kbd>" || string(source[segment.Start:segment.Stop]) == "</kbd>" {
_, _ = w.Write(segment.Value(source))
} else {
_, _ = w.Write(r.Filter.SanitizeBytes(segment.Value(source)))
}
}
return ast.WalkSkipChildren, nil
}

View File

@ -1,6 +1,7 @@
package plugin
import (
"github.com/answerdev/answer/internal/base/constant"
"github.com/gin-gonic/gin"
)
@ -15,3 +16,9 @@ var (
CallAgent,
registerAgent = MakePlugin[Agent](true)
)
// SiteURL The site url is the domain address of the current site. e.g. http://localhost:8080
// When some Agent plugins want to redirect to the origin site, it can use this function to get the site url.
func SiteURL() string {
return constant.DefaultSiteURL
}

View File

@ -44,13 +44,13 @@ type ConfigField struct {
}
type ConfigFieldUIOptions struct {
Placeholder Translator `json:"placeholder,omitempty"`
Rows string `json:"rows,omitempty"`
InputType InputType `json:"input_type,omitempty"`
Label Translator `json:"label,omitempty"`
Action *ConfigFieldUIOptionAction `json:"action,omitempty"`
Variant string `json:"variant,omitempty"`
Text Translator `json:"text,omitempty"`
Placeholder Translator `json:"placeholder,omitempty"`
Rows string `json:"rows,omitempty"`
InputType InputType `json:"input_type,omitempty"`
Label Translator `json:"label,omitempty"`
Action *UIOptionAction `json:"action,omitempty"`
Variant string `json:"variant,omitempty"`
Text Translator `json:"text,omitempty"`
}
type ConfigFieldOption struct {
@ -58,8 +58,29 @@ type ConfigFieldOption struct {
Value string `json:"value"`
}
type ConfigFieldUIOptionAction struct {
Url string `json:"url"`
type UIOptionAction struct {
Url string `json:"url"`
Method string `json:"method,omitempty"`
Loading *LoadingAction `json:"loading,omitempty"`
OnComplete *OnCompleteAction `json:"on_complete,omitempty"`
}
const (
LoadingActionStateNone LoadingActionType = "none"
LoadingActionStatePending LoadingActionType = "pending"
LoadingActionStateComplete LoadingActionType = "completed"
)
type LoadingActionType string
type LoadingAction struct {
Text Translator `json:"text"`
State LoadingActionType `json:"state"`
}
type OnCompleteAction struct {
ToastReturnMessage bool `json:"toast_return_message"`
RefreshFormConfig bool `json:"refresh_form_config"`
}
type Config interface {

View File

@ -140,7 +140,7 @@ type TranslateFn func(ctx *GinContext) string
// Translator contains a function that translates the key to the current language of the context
type Translator struct {
fn TranslateFn
Fn TranslateFn
}
// MakeTranslator generates a translator from the key
@ -148,13 +148,13 @@ func MakeTranslator(key string) Translator {
t := func(ctx *GinContext) string {
return Translate(ctx, key)
}
return Translator{fn: t}
return Translator{Fn: t}
}
// Translate translates the key to the current language of the context
func (t Translator) Translate(ctx *GinContext) string {
if &t == nil || t.fn == nil {
if &t == nil || t.Fn == nil {
return ""
}
return t.fn(ctx)
return t.Fn(ctx)
}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#0033FF" />
<meta name="generator" content="Answer - https://github.com/answerdev/answer">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
</head>

View File

@ -15,18 +15,6 @@ export const USER_AGENT_NAMES = {
DingTalk: 'DingTalk',
};
export const IGNORE_PATH_LIST = [
'/users/login',
'/users/register',
'/users/account-recovery',
'/users/change-email',
'/users/password-reset',
'/users/account-activation',
'/users/account-activation/success',
'/users/account-activation/failed',
'/users/confirm-new-email',
];
export const ADMIN_LIST_STATUS = {
// normal;
1: {

View File

@ -1,5 +1,3 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface FormValue<T = any> {
value: T;
isInvalid: boolean;
@ -549,6 +547,11 @@ export interface User {
avatar: string;
}
export interface QuestionOperationReq {
id: string;
operation: 'pin' | 'unpin' | 'hide' | 'show';
}
export interface OauthBindEmailReq {
binding_key: string;
email: string;
@ -565,27 +568,6 @@ export interface UserOauthConnectorItem extends OauthConnectorItem {
binding: boolean;
external_id: string;
}
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}
export interface QuestionOperationReq {
id: string;

View File

@ -11,6 +11,9 @@ const Index: FC = () => {
let primaryColor;
if (theme_config?.[theme]?.primary_color) {
primaryColor = Color(theme_config[theme].primary_color);
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', primaryColor.hex());
}
return (
@ -55,9 +58,12 @@ const Index: FC = () => {
--bs-pagination-active-border-color: ${primaryColor.hex()};
}
.form-select:focus,
.form-control:focus {
box-shadow: 0 0 0 0.25rem ${primaryColor.fade(0.75).string()};
border-color: ${tintColor(primaryColor, 0.5)};
.form-control:focus,
.form-control.focus{
box-shadow: 0 0 0 0.25rem ${primaryColor
.fade(0.75)
.string()} !important;
border-color: ${tintColor(primaryColor, 0.5)} !important;
}
.form-check-input:checked {
background-color: ${primaryColor.hex()};
@ -80,7 +86,7 @@ const Index: FC = () => {
color: ${primaryColor.hex()}!important;
}
.link-primary:hover, .link-primary:focus {
color: ${shadeColor(primaryColor, 0.8).hex()}!important
color: ${shadeColor(primaryColor, 0.8).hex()}!important;
}
`}
</style>

View File

@ -101,9 +101,12 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
variant="success"
id="dropdown-uca"
as="span"
className="no-toggle pointer p-0">
className="no-toggle">
<Nav>
<Icon name="grid-3x3-gap-fill" className="nav-link fs-4 ms-3" />
<Icon
name="grid-3x3-gap-fill"
className="nav-link pointer p-0 fs-4 ms-3"
/>
</Nav>
</Dropdown.Toggle>

View File

@ -5,7 +5,7 @@
background: linear-gradient(180deg, rgb(var(--bs-primary-rgb)) 0%, rgba(var(--bs-primary-rgb), 0.95) 100%);
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15), 0 0.125rem 0.25rem rgb(0 0 0 / 8%);
.logo {
max-height: 1.75rem;
max-height: 2rem;
}
.nav-link {

View File

@ -20,7 +20,7 @@ import {
import classnames from 'classnames';
import { floppyNavigation, userCenter } from '@/utils';
import { userCenter } from '@/utils';
import {
loggedUserInfoStore,
siteInfoStore,
@ -29,7 +29,6 @@ import {
themeSettingStore,
} from '@/stores';
import { logout, useQueryNotificationStatus } from '@/services';
import { RouteAlias } from '@/router/alias';
import NavItems from './components/NavItems';
@ -73,19 +72,6 @@ const Header: FC = () => {
clearUserStore();
window.location.replace(window.location.href);
};
const onLoginClick = (evt) => {
if (location.pathname === RouteAlias.login) {
evt.preventDefault();
window.location.reload();
return;
}
if (floppyNavigation.shouldProcessLinkClick(evt)) {
evt.preventDefault();
floppyNavigation.navigateToLogin({
handler: navigate,
});
}
};
useEffect(() => {
if (q) {
@ -123,17 +109,17 @@ const Header: FC = () => {
/>
<div className="d-flex justify-content-between align-items-center nav-grow flex-nowrap">
<Navbar.Brand to="/" as={Link} className="lh-1 me-0 me-sm-3">
<Navbar.Brand to="/" as={Link} className="lh-1 me-0 me-sm-3 p-0">
{brandingInfo.logo ? (
<>
<img
className="d-none d-lg-block logo rounded-1 me-0"
className="d-none d-lg-block logo me-0"
src={brandingInfo.logo}
alt=""
/>
<img
className="lg-none logo rounded-1 me-0"
className="lg-none logo me-0"
src={brandingInfo.mobile_logo || brandingInfo.logo}
alt=""
/>
@ -155,7 +141,6 @@ const Header: FC = () => {
'link-light': navbarStyle === 'theme-colored',
'link-primary': navbarStyle !== 'theme-colored',
})}
onClick={onLoginClick}
href={userCenter.getLoginUrl()}>
{t('btns.login')}
</Button>
@ -243,7 +228,6 @@ const Header: FC = () => {
'link-light': navbarStyle === 'theme-colored',
'link-primary': navbarStyle !== 'theme-colored',
})}
onClick={onLoginClick}
href={userCenter.getLoginUrl()}>
{t('btns.login')}
</Button>

View File

@ -10,7 +10,9 @@ import { useHotQuestions } from '@/services';
const HotQuestions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const { data: questionRes } = useHotQuestions();
if (!questionRes?.list?.length) {
return null;
}
return (
<Card>
<Card.Header className="text-nowrap text-capitalize">

View File

@ -66,8 +66,13 @@ const Index: FC<{
contentClassName="bg-transparent"
onHide={onClose}>
<Modal.Body onClick={onClose} className="img-viewer p-0 d-flex">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions */}
<img
className="cursor-zoom-out img-fluid m-auto"
onClick={(evt) => {
evt.stopPropagation();
onClose();
}}
src={imgSrc}
alt={imgSrc}
/>

View File

@ -51,7 +51,7 @@ const Index: React.FC<IProps> = ({
type="text"
autoComplete="off"
placeholder={t('placeholder')}
isInvalid={captcha.isInvalid}
isInvalid={captcha?.isInvalid}
onChange={(e) => {
Storage.set(CAPTCHA_CODE_STORAGE_KEY, e.target.value);
handleCaptcha({

View File

@ -1,15 +1,15 @@
import React, { FC, useState } from 'react';
import { Button, ButtonProps } from 'react-bootstrap';
import React, { FC, useLayoutEffect, useState } from 'react';
import { Button, ButtonProps, Spinner } from 'react-bootstrap';
import { request } from '@/utils';
import type * as Type from '@/common/interface';
import type { UIAction } from '../index.d';
import type { UIAction, FormKit } from '../types';
import { useToast } from '@/hooks';
interface Props {
fieldName: string;
text: string;
action: UIAction | undefined;
formData: Type.FormDataType;
formKit: FormKit;
readOnly: boolean;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
@ -17,24 +17,65 @@ interface Props {
const Index: FC<Props> = ({
fieldName,
action,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
formData,
formKit,
text = '',
readOnly = false,
variant = 'primary',
size,
}) => {
const Toast = useToast();
const [isLoading, setLoading] = useState(false);
const handleAction = async () => {
const handleToast = (msg, type: 'success' | 'danger' = 'success') => {
const tm = action?.on_complete?.toast_return_message;
if (tm === false || !msg) {
return;
}
Toast.onShow({
msg,
variant: type,
});
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleCallback = (resp) => {
const callback = action?.on_complete;
if (callback?.refresh_form_config) {
formKit.refreshConfig();
}
};
const handleAction = () => {
if (!action) {
return;
}
setLoading(true);
const method = action.method || 'get';
await request[method](action.url);
setLoading(false);
request
.request({
method: action.method,
url: action.url,
timeout: 0,
})
.then((resp) => {
if ('message' in resp) {
handleToast(resp.message, 'success');
}
handleCallback(resp);
})
.catch((ex) => {
if (ex && 'msg' in ex) {
handleToast(ex.msg, 'danger');
}
})
.finally(() => {
setLoading(false);
});
};
useLayoutEffect(() => {
if (action?.loading?.state === 'pending') {
setLoading(true);
}
}, []);
const loadingText = action?.loading?.text || text;
const disabled = isLoading || readOnly;
return (
<div className="d-flex">
<Button
@ -43,8 +84,19 @@ const Index: FC<Props> = ({
disabled={disabled}
size={size}
variant={variant}>
{text || fieldName}
{isLoading ? '...' : ''}
{isLoading ? (
<>
<Spinner
className="align-middle me-2"
animation="border"
size="sm"
variant={variant}
/>
{loadingText}
</>
) : (
text
)}
</Button>
</div>
);

View File

@ -1,6 +0,0 @@
export interface UIAction {
url: string;
method?: 'get' | 'post' | 'put' | 'delete';
event?: 'click' | 'change';
handler?: ({evt, formData, request}) => Promise<void>
}

View File

@ -4,14 +4,15 @@ import React, {
useImperativeHandle,
useEffect,
} from 'react';
import { Form, Button, ButtonProps } from 'react-bootstrap';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { isEmpty } from 'lodash';
import classnames from 'classnames';
import type * as Type from '@/common/interface';
import type { UIAction } from './index.d';
import type { JSONSchema, UISchema, BaseUIOptions, FormKit } from './types';
import {
Legend,
Select,
@ -21,122 +22,16 @@ import {
Upload,
Textarea,
Input,
Button as CtrlButton,
Button as SfButton,
} from './components';
export interface JSONSchema {
title: string;
description?: string;
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean' | 'number';
title: string;
description?: string;
enum?: Array<string | boolean | number>;
enumNames?: string[];
default?: string | boolean | number;
};
};
}
export interface BaseUIOptions {
empty?: string;
// Will be appended to the className of the form component itself
className?: classnames.Argument;
// The className that will be attached to a form field container
fieldClassName?: classnames.Argument;
// Make a form component render into simplified mode
readOnly?: boolean;
simplify?: boolean;
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
inputType?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
}
export interface SelectOptions extends BaseUIOptions {}
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: Type.UploadType;
}
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
export interface TimezoneOptions extends BaseUIOptions {
placeholder?: string;
}
export interface CheckboxOptions extends BaseUIOptions {}
export interface RadioOptions extends BaseUIOptions {}
export interface TextareaOptions extends BaseUIOptions {
placeholder?: string;
rows?: number;
}
export interface ButtonOptions extends BaseUIOptions {
text: string;
icon?: string;
action?: UIAction;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
}
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions
| ButtonOptions;
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch'
| 'legend'
| 'button';
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?: UIOptions;
};
}
export * from './types';
interface IProps {
schema: JSONSchema;
schema: JSONSchema | null;
formData: Type.FormDataType | null;
uiSchema?: UISchema;
formData?: Type.FormDataType;
refreshConfig?: FormKit['refreshConfig'];
hiddenSubmit?: boolean;
onChange?: (data: Type.FormDataType) => void;
onSubmit?: (e: React.FormEvent) => void;
@ -148,6 +43,7 @@ interface IRef {
/**
* TODO:
* - [!] Standardised `Admin/Plugins/Config/index.tsx` method for generating dynamic form configurations.
* - Normalize and document `formData[key].hidden && 'd-none'`
* - Normalize and document `hiddenSubmit`
* - Improving field hints for `formData`
@ -168,7 +64,8 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
{
schema,
uiSchema = {},
formData = {},
refreshConfig,
formData,
onChange,
onSubmit,
hiddenSubmit = false,
@ -181,11 +78,10 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
const { required = [], properties = {} } = schema || {};
// check required field
const excludes = required.filter((key) => !properties[key]);
if (excludes.length > 0) {
console.error(t('not_found_props', { key: excludes.join(', ') }));
}
formData ||= {};
const keys = Object.keys(properties);
/**
* Prevent components such as `select` from having default values,
@ -193,14 +89,14 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
*/
const setDefaultValueAsDomBehaviour = () => {
keys.forEach((k) => {
const fieldVal = formData[k]?.value;
const fieldVal = formData![k]?.value;
const metaProp = properties[k];
const uiCtrl = uiSchema[k]?.['ui:widget'];
if (!metaProp || !uiCtrl || fieldVal !== undefined) {
return;
}
if (uiCtrl === 'select' && metaProp.enum?.[0] !== undefined) {
formData[k] = {
formData![k] = {
errorMsg: '',
isInvalid: false,
value: metaProp.enum?.[0],
@ -212,10 +108,22 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
setDefaultValueAsDomBehaviour();
}, [formData]);
const formKitWithContext: FormKit = {
refreshConfig() {
if (typeof refreshConfig === 'function') {
refreshConfig();
}
},
};
/**
* Form validation
* - Currently only dynamic forms are in use, the business form validation has been handed over to the server
*/
const requiredValidator = () => {
const errors: string[] = [];
required.forEach((key) => {
if (!formData[key] || !formData[key].value) {
if (!formData![key] || !formData![key].value) {
errors.push(key);
}
});
@ -231,7 +139,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
keys.forEach((key) => {
const { validator } = uiSchema[key]?.['ui:options'] || {};
if (validator instanceof Function) {
const value = formData[key]?.value;
const value = formData![key]?.value;
promises.push({
key,
promise: validator(value, formData),
@ -269,14 +177,14 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
if (errors.length > 0) {
formData = errors.reduce((acc, cur) => {
acc[cur] = {
...formData[cur],
...formData![cur],
isInvalid: true,
errorMsg:
uiSchema[cur]?.['ui:options']?.empty ||
`${schema.properties[cur]?.title} ${t('empty')}`,
`${properties[cur]?.title} ${t('empty')}`,
};
return acc;
}, formData);
}, formData || {});
if (onChange instanceof Function) {
onChange({ ...formData });
}
@ -286,13 +194,12 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
if (syncErrors.length > 0) {
formData = syncErrors.reduce((acc, cur) => {
acc[cur.key] = {
...formData[cur.key],
...formData![cur.key],
isInvalid: true,
errorMsg:
cur.msg || `${schema.properties[cur.key].title} ${t('invalid')}`,
errorMsg: cur.msg || `${properties[cur.key].title} ${t('invalid')}`,
};
return acc;
}, formData);
}, formData || {});
if (onChange instanceof Function) {
onChange({ ...formData });
}
@ -308,12 +215,12 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
return;
}
Object.keys(formData).forEach((key) => {
formData[key].isInvalid = false;
formData[key].errorMsg = '';
Object.keys(formData!).forEach((key) => {
formData![key].isInvalid = false;
formData![key].errorMsg = '';
});
if (onChange instanceof Function) {
onChange(formData);
onChange(formData!);
}
if (onSubmit instanceof Function) {
onSubmit(e);
@ -323,9 +230,10 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
useImperativeHandle(ref, () => ({
validator,
}));
if (!formData || !schema || !schema.properties) {
if (!formData || !schema || isEmpty(schema.properties)) {
return null;
}
return (
<Form noValidate onSubmit={handleSubmit}>
{keys.map((key) => {
@ -336,7 +244,8 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
enumNames = [],
} = properties[key];
const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } =
uiSchema[key] || {};
uiSchema?.[key] || {};
formData ||= {};
const fieldState = formData[key];
const uiSimplify = widget === 'legend' || uiOpt?.simplify;
let groupClassName: BaseUIOptions['fieldClassName'] = uiOpt?.simplify
@ -441,11 +350,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
/>
) : null}
{widget === 'button' ? (
<CtrlButton
<SfButton
fieldName={key}
text={uiOpt && 'text' in uiOpt ? uiOpt.text : ''}
action={uiOpt && 'action' in uiOpt ? uiOpt.action : undefined}
formData={formData}
formKit={formKitWithContext}
readOnly={readOnly}
variant={
uiOpt && 'variant' in uiOpt ? uiOpt.variant : undefined
@ -473,8 +382,9 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
};
export const initFormData = (schema: JSONSchema): Type.FormDataType => {
const formData: Type.FormDataType = {};
Object.keys(schema.properties).forEach((key) => {
const prop = schema.properties[key];
const props: JSONSchema['properties'] = schema?.properties || {};
Object.keys(props).forEach((key) => {
const prop = props[key];
const defaultVal = prop?.default;
formData[key] = {
@ -486,4 +396,27 @@ export const initFormData = (schema: JSONSchema): Type.FormDataType => {
return formData;
};
export const mergeFormData = (
target: Type.FormDataType | null,
origin: Type.FormDataType | null,
) => {
if (!target) {
return origin;
}
if (!origin) {
return target;
}
Object.keys(target).forEach((k) => {
const oi = origin[k];
if (oi && oi.value !== undefined) {
target[k] = {
value: oi.value,
isInvalid: false,
errorMsg: '',
};
}
});
return target;
};
export default forwardRef(SchemaForm);

View File

@ -0,0 +1,152 @@
import { ButtonProps } from 'react-bootstrap';
import classnames from 'classnames';
import * as Type from '@/common/interface';
export interface JSONSchema {
title: string;
description?: string;
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean' | 'number';
title: string;
description?: string;
enum?: Array<string | boolean | number>;
enumNames?: string[];
default?: string | boolean | number;
};
};
}
export interface BaseUIOptions {
empty?: string;
// Will be appended to the className of the form component itself
className?: classnames.Argument;
// The className that will be attached to a form field container
fieldClassName?: classnames.Argument;
// Make a form component render into simplified mode
readOnly?: boolean;
simplify?: boolean;
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
inputType?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
}
export interface SelectOptions extends BaseUIOptions {}
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: Type.UploadType;
}
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
export interface TimezoneOptions extends BaseUIOptions {
placeholder?: string;
}
export interface CheckboxOptions extends BaseUIOptions {}
export interface RadioOptions extends BaseUIOptions {}
export interface TextareaOptions extends BaseUIOptions {
placeholder?: string;
rows?: number;
}
export interface ButtonOptions extends BaseUIOptions {
text: string;
icon?: string;
action?: UIAction;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
}
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions
| ButtonOptions;
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch'
| 'legend'
| 'button';
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?: UIOptions;
};
}
/**
* A few notes on button control
* - Mainly used to send a request and notify the result of the request, and to update the data as required
* - A scenario where a message notification is displayed directly after a click without sending a request, implementing a dedicated control
* - Scenarios where the page jumps directly after a click without sending a request, implementing a dedicated control
*
* @field url : Target address for sending requests
* @field method : Method for sending requests, default `get`
* @field callback: Button event handler function that will fully take over the button events when this field is configured
* *** Incomplete, DO NOT USE ***
* @field loading: Set button loading information
* @field on_complete: What needs to be done when the `Action` completes
* @field on_complete.toast_return_message: Does toast show the returned message
* @field on_complete.refresh_form_config: Whether to refresh the form configuration (configuration only, no data included)
*/
export interface UIAction {
url: string;
method?: 'get' | 'post' | 'put' | 'delete';
loading?: {
text: string;
state?: 'none' | 'pending' | 'completed';
};
on_complete?: {
toast_return_message?: boolean;
refresh_form_config?: boolean;
};
}
/**
* Form tools
* - Used to get or set the configuration of forms and form items, the value of a form item
* * @method refreshConfig(): void
*/
export interface FormKit {
refreshConfig(): void;
}

View File

@ -73,7 +73,7 @@ const Flags: FC = () => {
{flagTypeKeys.map((li) => {
return (
<option value={li} key={li}>
{li}
{t(li, { keyPrefix: 'btns' })}
</option>
);
})}
@ -96,7 +96,9 @@ const Flags: FC = () => {
<td>
<Stack>
<small className="text-secondary">
Flagged {li.object_type}
{t('flagged_type', {
type: t(li.object_type, { keyPrefix: 'btns' }),
})}
</small>
<BaseUserCard
data={li.reported_user}

View File

@ -2,18 +2,23 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { isEmpty } from 'lodash';
import { useToast } from '@/hooks';
import type * as Types from '@/common/interface';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { SchemaForm, JSONSchema, UISchema } from '@/components';
import { useQueryPluginConfig, updatePluginConfig } from '@/services';
import { InputOptions } from '@/components/SchemaForm';
import {
InputOptions,
FormKit,
initFormData,
mergeFormData,
} from '@/components/SchemaForm';
const Config = () => {
const { t } = useTranslation('translation');
const { slug_name } = useParams<{ slug_name: string }>();
const { data } = useQueryPluginConfig({ plugin_slug_name: slug_name });
const { data, mutate: refreshPluginConfig } = useQueryPluginConfig({
plugin_slug_name: slug_name,
});
const Toast = useToast();
const [schema, setSchema] = useState<JSONSchema | null>(null);
const [uiSchema, setUISchema] = useState<UISchema>();
@ -62,7 +67,7 @@ const Config = () => {
};
setSchema(result);
setUISchema(uiConf);
setFormData(initFormData(result));
setFormData(mergeFormData(initFormData(result), formData));
}, [data?.config_fields]);
const onSubmit = (evt) => {
@ -86,24 +91,19 @@ const Config = () => {
});
});
};
const refreshConfig: FormKit['refreshConfig'] = async () => {
refreshPluginConfig();
};
const handleOnChange = (form) => {
setFormData(form);
};
if (!data || !schema || !formData) {
return null;
}
if (isEmpty(schema.properties)) {
return <h3 className="mb-4">{data?.name}</h3>;
}
return (
<>
<h3 className="mb-4">{data?.name}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
refreshConfig={refreshConfig}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}

View File

@ -80,7 +80,7 @@ const Index: FC = () => {
formMeta.robots.value = setting.robots;
formMeta.permalink.value = setting.permalink;
if (!/[1234]/.test(formMeta.permalink.value)) {
formMeta.permalink.value = 1;
formMeta.permalink.value = 4;
}
setFormData(formMeta);
}

View File

@ -57,8 +57,10 @@ const Users: FC = () => {
const currentUser = loggedUserInfoStore((state) => state.user);
const { agent: ucAgent } = userCenterStore();
const [adminUcAgent, setAdminUcAgent] = useState<AdminUcAgent>({
user_status_agent_enabled: false,
user_password_agent_enabled: false,
allow_create_user: true,
allow_update_user_status: true,
allow_update_user_password: true,
allow_update_user_role: true,
});
const Toast = useToast();
const {
@ -156,6 +158,18 @@ const Users: FC = () => {
});
}
}, [ucAgent]);
const showAddUser =
!ucAgent?.enabled || (ucAgent?.enabled && adminUcAgent?.allow_create_user);
const showActionPassword =
!ucAgent?.enabled ||
(ucAgent?.enabled && adminUcAgent?.allow_update_user_password);
const showActionRole =
!ucAgent?.enabled ||
(ucAgent?.enabled && adminUcAgent?.allow_update_user_role);
const showActionStatus =
!ucAgent?.enabled ||
(ucAgent?.enabled && adminUcAgent?.allow_update_user_status);
const showAction = showActionPassword || showActionRole || showActionStatus;
return (
<>
<h3 className="mb-4">{t('title')}</h3>
@ -167,7 +181,7 @@ const Users: FC = () => {
sortKey="filter"
i18nKeyPrefix="admin.users"
/>
{!ucAgent?.enabled ? (
{showAddUser ? (
<Button
variant="outline-primary"
size="sm"
@ -252,31 +266,31 @@ const Users: FC = () => {
</span>
</td>
)}
{curFilter !== 'deleted' ? (
{curFilter !== 'deleted' && showAction ? (
<td className="text-end">
<Dropdown>
<Dropdown.Toggle variant="link" className="no-toggle">
<Icon name="three-dots-vertical" />
</Dropdown.Toggle>
<Dropdown.Menu>
{!ucAgent?.enabled ||
!adminUcAgent.user_password_agent_enabled ? (
{showActionPassword ? (
<Dropdown.Item
onClick={() => handleAction('password', user)}>
{t('set_new_password')}
</Dropdown.Item>
) : null}
{!ucAgent?.enabled ||
!adminUcAgent.user_status_agent_enabled ? (
{showActionStatus ? (
<Dropdown.Item
onClick={() => handleAction('status', user)}>
{t('change_status')}
</Dropdown.Item>
) : null}
<Dropdown.Item
onClick={() => handleAction('role', user)}>
{t('change_role')}
</Dropdown.Item>
{showActionRole ? (
<Dropdown.Item
onClick={() => handleAction('role', user)}>
{t('change_role')}
</Dropdown.Item>
) : null}
</Dropdown.Menu>
</Dropdown>
</td>

View File

@ -11,6 +11,7 @@ import {
Comment,
FormatTime,
htmlRender,
ImgViewer,
} from '@/components';
import { scrollToElementTop, bgFadeOut } from '@/utils';
import { AnswerItem } from '@/common/interface';
@ -84,10 +85,12 @@ const Index: FC<Props> = ({
</Badge>
</div>
)}
<article
className="fmt text-break text-wrap"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
<ImgViewer>
<article
className="fmt text-break text-wrap"
dangerouslySetInnerHTML={{ __html: data?.html }}
/>
</ImgViewer>
<div className="d-flex align-items-center mt-4">
<Actions
source="answer"

View File

@ -64,8 +64,8 @@ const Tags = () => {
<Row className="mb-4 d-flex justify-content-center">
<Col xxl={10} sm={12}>
<h3 className="mb-4">{t('title')}</h3>
<div className="d-flex justify-content-between align-items-center flex-wrap">
<Stack direction="horizontal" gap={3}>
<div className="d-block d-sm-flex justify-content-between align-items-center flex-wrap">
<Stack direction="horizontal" gap={3} className="mb-3 mb-sm-0">
<Form>
<Form.Group controlId="formBasicEmail">
<Form.Control

View File

@ -1,6 +1,7 @@
import React, { memo, FC, useState, useEffect } from 'react';
import { Card } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import QrCode from 'qrcode';
@ -13,6 +14,7 @@ import { getLoginConf, checkLoginResult } from './service';
let checkTimer: NodeJS.Timeout;
const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'plugins' });
const navigate = useNavigate();
const ucAgent = userCenterStore().agent;
const agentName = ucAgent?.agent_info?.name || '';
const [qrcodeDataUrl, setQrCodeDataUrl] = useState('');
@ -22,7 +24,7 @@ const Index: FC = () => {
}
checkLoginResult(key).then((res) => {
if (res.is_login) {
guard.handleLoginWithToken(res.token);
guard.handleLoginWithToken(res.token, navigate);
return;
}
clearTimeout(checkTimer);
@ -73,8 +75,8 @@ const Index: FC = () => {
{qrcodeDataUrl ? (
<>
<img
width={240}
height={240}
className="w-100"
style={{ maxWidth: '240px' }}
src={qrcodeDataUrl}
alt={agentName}
/>

View File

@ -22,7 +22,7 @@ const Index: React.FC = () => {
<Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}>
<h3 className="text-center mb-5">{t('page_title')}</h3>
{step === 1 && (
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<SendEmail visible={step === 1} callback={callback} />
</Col>
)}

View File

@ -15,7 +15,7 @@ const Index: FC = () => {
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}>
<WelcomeTitle />
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<SendEmail />
</Col>
</Container>

View File

@ -175,86 +175,90 @@ const Index: React.FC = () => {
return (
<Container style={{ paddingTop: '4rem', paddingBottom: '5rem' }}>
<WelcomeTitle />
{step === 1 && canOriginalLogin ? (
<Col className="mx-auto" md={3}>
{step === 1 ? (
<Col className="mx-auto" md={6} lg={4} xl={3}>
{ucAgentInfo ? (
<PluginUcLogin className="mb-5" />
) : (
<PluginOauth className="mb-5" />
)}
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="email" className="mb-3">
<Form.Label>{t('email.label')}</Form.Label>
<Form.Control
required
tabIndex={1}
type="email"
value={formData.e_mail.value}
isInvalid={formData.e_mail.isInvalid}
onChange={(e) =>
handleChange({
e_mail: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.e_mail.errorMsg}
</Form.Control.Feedback>
</Form.Group>
{canOriginalLogin ? (
<>
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="email" className="mb-3">
<Form.Label>{t('email.label')}</Form.Label>
<Form.Control
required
tabIndex={1}
type="email"
value={formData.e_mail.value}
isInvalid={formData.e_mail.isInvalid}
onChange={(e) =>
handleChange({
e_mail: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.e_mail.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="password" className="mb-3">
<div className="d-flex justify-content-between">
<Form.Label>{t('password.label')}</Form.Label>
<Link to="/users/account-recovery" tabIndex={2}>
<small>{t('forgot_pass')}</small>
</Link>
</div>
<Form.Group controlId="password" className="mb-3">
<div className="d-flex justify-content-between">
<Form.Label>{t('password.label')}</Form.Label>
<Link to="/users/account-recovery" tabIndex={2}>
<small>{t('forgot_pass')}</small>
</Link>
</div>
<Form.Control
required
tabIndex={1}
type="password"
// value={formData.pass.value}
maxLength={32}
isInvalid={formData.pass.isInvalid}
onChange={(e) =>
handleChange({
pass: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.pass.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Control
required
tabIndex={1}
type="password"
// value={formData.pass.value}
maxLength={32}
isInvalid={formData.pass.isInvalid}
onChange={(e) =>
handleChange({
pass: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.pass.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<div className="d-grid">
<Button variant="primary" type="submit" tabIndex={1}>
{t('login', { keyPrefix: 'btns' })}
</Button>
</div>
</Form>
{loginSetting.allow_new_registrations && (
<div className="text-center mt-5">
<Trans i18nKey="login.info_sign" ns="translation">
Dont have an account?
<Link
to={userCenter.getSignUpUrl()}
tabIndex={2}
onClick={floppyNavigation.handleRouteLinkClick}>
Sign up
</Link>
</Trans>
</div>
)}
<div className="d-grid">
<Button variant="primary" type="submit" tabIndex={1}>
{t('login', { keyPrefix: 'btns' })}
</Button>
</div>
</Form>
{loginSetting.allow_new_registrations && (
<div className="text-center mt-5">
<Trans i18nKey="login.info_sign" ns="translation">
Dont have an account?
<Link
to={userCenter.getSignUpUrl()}
tabIndex={2}
onClick={floppyNavigation.handleRouteLinkClick}>
Sign up
</Link>
</Trans>
</div>
)}
</>
) : null}
</Col>
) : null}

View File

@ -1,5 +1,6 @@
import { ListGroup } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { isEmpty } from 'lodash';
@ -7,6 +8,7 @@ import { isEmpty } from 'lodash';
import { FormatTime, Empty } from '@/components';
const Inbox = ({ data, handleReadNotification }) => {
const { t } = useTranslation('translation', { keyPrefix: 'notifications' });
if (!data) {
return null;
}
@ -40,12 +42,13 @@ const Inbox = ({ data, handleReadNotification }) => {
!item.is_read && 'warning',
)}>
<div>
{item.user_info.status !== 'deleted' ? (
{item.user_info && item.user_info.status !== 'deleted' ? (
<Link to={`/users/${item.user_info.username}`}>
{item.user_info.display_name}{' '}
</Link>
) : (
<span>{item.user_info.display_name} </span>
// someone for anonymous user display
<span>{item.user_info?.display_name || t('someone')} </span>
)}
{item.notification_action}{' '}
<Link to={url} onClick={() => handleReadNotification(item.id)}>

View File

@ -148,7 +148,7 @@ const Index: FC = () => {
<p>{t('info', { keyPrefix: 'inactive' })}</p>
</Col>
) : (
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<div className="text-center mb-5">{t('subtitle')}</div>
<Form noValidate onSubmit={handleSubmit} autoComplete="off">
<Form.Group controlId="email" className="mb-3">

View File

@ -119,7 +119,7 @@ const Index: React.FC = () => {
<Container style={{ paddingTop: '4rem', paddingBottom: '6rem' }}>
<h3 className="text-center mb-5">{t('page_title')}</h3>
{step === 1 && (
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<Form noValidate onSubmit={handleSubmit} autoComplete="off">
<Form.Group controlId="email" className="mb-3">
<Form.Label>{t('password.label')}</Form.Label>

View File

@ -26,7 +26,7 @@ const Index: React.FC = () => {
<WelcomeTitle />
{showForm ? (
<Col className="mx-auto" md={3}>
<Col className="mx-auto" md={6} lg={4} xl={3}>
<PluginOauth className="mb-5" />
<SignUpForm callback={onStep} />
</Col>

View File

@ -4,20 +4,37 @@ import { useTranslation } from 'react-i18next';
import type * as Type from '@/common/interface';
import { useToast } from '@/hooks';
import { getLoggedUserInfo, changeEmail } from '@/services';
import { getLoggedUserInfo, changeEmail, checkImgCode } from '@/services';
import { handleFormError } from '@/utils';
import { PicAuthCodeModal } from '@/components/Modal';
const Index: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'settings.account',
});
const [step, setStep] = useState(1);
const [showModal, setModalState] = useState(false);
const [imgCode, setImgCode] = useState<Type.ImgCodeRes>({
captcha_id: '',
captcha_img: '',
verify: false,
});
const [formData, setFormData] = useState<Type.FormDataType>({
e_mail: {
value: '',
isInvalid: false,
errorMsg: '',
},
pass: {
value: '',
isInvalid: false,
errorMsg: '',
},
captcha_code: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
const [userInfo, setUserInfo] = useState<Type.UserInfoRes>();
const toast = useToast();
@ -27,13 +44,21 @@ const Index: FC = () => {
});
}, []);
const getImgCode = () => {
checkImgCode({
action: 'e_mail',
}).then((res) => {
setImgCode(res);
});
};
const handleChange = (params: Type.FormDataType) => {
setFormData({ ...formData, ...params });
};
const checkValidated = (): boolean => {
let bol = true;
const { e_mail } = formData;
const { e_mail, pass } = formData;
if (!e_mail.value) {
bol = false;
@ -43,41 +68,89 @@ const Index: FC = () => {
errorMsg: t('email.msg'),
};
}
if (!pass.value) {
bol = false;
formData.pass = {
value: '',
isInvalid: true,
errorMsg: t('pass.msg'),
};
}
setFormData({
...formData,
});
return bol;
};
const initFormData = () => {
setFormData({
e_mail: {
value: '',
isInvalid: false,
errorMsg: '',
},
pass: {
value: '',
isInvalid: false,
errorMsg: '',
},
captcha_code: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
};
const postEmail = (event?: any) => {
if (event) {
event.preventDefault();
}
const params: any = {
e_mail: formData.e_mail.value,
pass: formData.pass.value,
};
if (imgCode.verify) {
params.captcha_code = formData.captcha_code.value;
params.captcha_id = imgCode.captcha_id;
}
changeEmail(params)
.then(() => {
setStep(1);
setModalState(false);
toast.onShow({
msg: t('change_email_info'),
variant: 'warning',
});
initFormData();
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
if (!err.list.find((v) => v.error_field.indexOf('captcha') >= 0)) {
setModalState(false);
}
}
})
.finally(() => {
getImgCode();
});
};
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
event.stopPropagation();
if (!checkValidated()) {
return;
}
changeEmail({
e_mail: formData.e_mail.value,
})
.then(() => {
setStep(1);
toast.onShow({
msg: t('change_email_info'),
variant: 'warning',
});
setFormData({
e_mail: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
if (imgCode.verify) {
setModalState(true);
}
postEmail();
};
return (
@ -96,13 +169,41 @@ const Index: FC = () => {
/>
</Form.Group>
<Button variant="outline-secondary" onClick={() => setStep(2)}>
<Button
variant="outline-secondary"
onClick={() => {
setStep(2);
getImgCode();
}}>
{t('change_email_btn')}
</Button>
</Form>
)}
{step === 2 && (
<Form noValidate onSubmit={handleSubmit}>
<Form.Group controlId="currentPass" className="mb-3">
<Form.Label>{t('pass.label')}</Form.Label>
<Form.Control
autoComplete="new-password"
required
type="password"
maxLength={32}
isInvalid={formData.pass.isInvalid}
onChange={(e) =>
handleChange({
pass: {
value: e.target.value,
isInvalid: false,
errorMsg: '',
},
})
}
/>
<Form.Control.Feedback type="invalid">
{formData.pass.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<Form.Group controlId="newEmail" className="mb-3">
<Form.Label>{t('email.label')}</Form.Label>
<Form.Control
@ -126,6 +227,7 @@ const Index: FC = () => {
{formData.e_mail.errorMsg}
</Form.Control.Feedback>
</Form.Group>
<div>
<Button type="submit" variant="primary" className="me-2">
{t('save', { keyPrefix: 'btns' })}
@ -137,6 +239,18 @@ const Index: FC = () => {
</div>
</Form>
)}
<PicAuthCodeModal
visible={showModal}
data={{
captcha: formData.captcha_code,
imgCode,
}}
handleCaptcha={handleChange}
clickSubmit={postEmail}
refreshImgCode={getImgCode}
onClose={() => setModalState(false)}
/>
</div>
);
};

23
ui/src/plugins/types.ts Normal file
View File

@ -0,0 +1,23 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}

View File

@ -1,5 +1,5 @@
import { FC, ReactNode, useEffect, useState } from 'react';
import { useLocation, useNavigate, useLoaderData } from 'react-router-dom';
import { useNavigate, useLoaderData } from 'react-router-dom';
import { floppyNavigation } from '@/utils';
import { TGuardFunc, TGuardResult } from '@/utils/guard';
@ -13,7 +13,6 @@ const RouteGuard: FC<{
page?: string;
}> = ({ children, onEnter, path, page }) => {
const navigate = useNavigate();
const location = useLocation();
const loaderData = useLoaderData();
const [gk, setKeeper] = useState<TGuardResult>({
ok: true,
@ -23,6 +22,7 @@ const RouteGuard: FC<{
if (typeof onEnter !== 'function') {
return;
}
const gr = onEnter({
loaderData,
path,
@ -48,12 +48,10 @@ const RouteGuard: FC<{
};
useEffect(() => {
/**
* NOTICE:
* Must be put in `useEffect`,
* otherwise `guard` may not get `loggedUserInfo` correctly
* By detecting changes to location.href, many unnecessary tests can be avoided
*/
applyGuard();
}, [location]);
}, [window.location.href]);
let asOK = gk.ok;
if (gk.ok === false && gk.redirect) {
@ -62,10 +60,8 @@ const RouteGuard: FC<{
* but the current page is already the target page for the route guard jump
* This should render `children`!
*/
asOK = floppyNavigation.equalToCurrentHref(gk.redirect);
}
return (
<>
{asOK ? children : null}

View File

@ -2,7 +2,15 @@ export const RouteAlias = {
home: '/',
login: '/users/login',
signUp: '/users/register',
activation: '/users/login?status=inactive',
inactive: '/users/login?status=inactive',
accountRecovery: '/users/account-recovery',
changeEmail: '/users/change-email',
passwordReset: '/users/password-reset',
accountActivation: '/users/account-activation',
activationSuccess: '/users/account-activation/success',
activationFailed: '/users/account-activation/failed',
suspended: '/users/account-suspended',
confirmNewEmail: '/users/confirm-new-email',
confirmEmail: '/users/confirm-email',
authLanding: '/users/auth-landing',
};

View File

@ -269,7 +269,7 @@ const routes: RouteNode[] = [
path: 'admin',
page: 'pages/Admin',
loader: async () => {
await guard.pullLoggedUser(true);
await guard.pullLoggedUser();
return null;
},
guard: () => {

View File

@ -1,8 +1,8 @@
import qs from 'qs';
import useSWR from 'swr';
import type * as Types from '@/common/interface';
import request from '@/utils/request';
import type { PluginConfig } from '@/plugins/types';
export const useQueryPlugins = (params) => {
const apiUrl = `/answer/admin/api/plugins?${qs.stringify(params)}`;
@ -24,7 +24,7 @@ export const updatePluginStatus = (params) => {
export const useQueryPluginConfig = (params) => {
const apiUrl = `/answer/admin/api/plugin/config?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<Types.PluginConfig, Error>(
const { data, error, mutate } = useSWR<PluginConfig, Error>(
apiUrl,
request.instance.get,
);

View File

@ -249,7 +249,7 @@ export const closeQuestion = (params: {
return request.put('/answer/api/v1/question/status', params);
};
export const changeEmail = (params: { e_mail: string }) => {
export const changeEmail = (params: { e_mail: string; pass?: string }) => {
return request.post('/answer/api/v1/user/email/change/code', params);
};

View File

@ -40,8 +40,10 @@ export interface UcBranding {
}
export interface AdminUcAgent {
user_status_agent_enabled: boolean;
user_password_agent_enabled: boolean;
allow_create_user: boolean;
allow_update_user_status: boolean;
allow_update_user_password: boolean;
allow_update_user_role: boolean;
}
export const getUcAgent = () => {

View File

@ -31,6 +31,9 @@ const initUser: UserInfoRes = {
const loggedUserInfo = create<UserInfoStore>((set) => ({
user: initUser,
update: (params) => {
if (typeof params !== 'object' || !params) {
return;
}
if (!params?.language) {
params.language = 'Default';
}

View File

@ -60,17 +60,18 @@ const navigate = (to: string | number, config: NavigateConfig = {}) => {
/**
* 1. Blocking redirection of two login pages
* 2. Auto storage login redirect
* Note: The or judgement cannot be missing here, both jumps will be used
*/
if (to === RouteAlias.login || to === getLoginUrl()) {
if (equalToCurrentHref(RouteAlias.login)) {
return;
}
storageLoginRedirect();
}
if (!isRoutableLink(to) && handler !== 'href' && handler !== 'replace') {
handler = 'href';
}
if (handler === 'href' && config.options?.replace) {
handler = 'replace';
}
if (handler === 'href') {
window.location.href = to;
} else if (handler === 'replace') {
@ -85,10 +86,11 @@ const navigate = (to: string | number, config: NavigateConfig = {}) => {
};
/**
* auto navigate to login page with redirect info
* auto navigate to login page
* Note: Only the internal login page is jumped here, `userAgent` login is handled on the internal login page.
*/
const navigateToLogin = (config?: NavigateConfig) => {
const loginUrl = getLoginUrl();
const loginUrl = RouteAlias.login;
navigate(loginUrl, config);
};

View File

@ -20,7 +20,7 @@ import Storage from '@/utils/storage';
import { setupAppLanguage, setupAppTimeZone } from './localize';
import { floppyNavigation, NavigateConfig } from './floppyNavigation';
import { pullUcAgent, getLoginUrl, getSignUpUrl } from './userCenter';
import { pullUcAgent, getSignUpUrl } from './userCenter';
type TLoginState = {
isLogged: boolean;
@ -81,6 +81,19 @@ export const deriveLoginState = (): TLoginState => {
return ls;
};
export const IGNORE_PATH_LIST = [
RouteAlias.login,
RouteAlias.signUp,
RouteAlias.accountRecovery,
RouteAlias.changeEmail,
RouteAlias.passwordReset,
RouteAlias.accountActivation,
RouteAlias.confirmNewEmail,
RouteAlias.confirmEmail,
RouteAlias.authLanding,
'/user-center/',
];
export const isIgnoredPath = (ignoredPath: string | string[]) => {
if (!Array.isArray(ignoredPath)) {
ignoredPath = [ignoredPath];
@ -92,15 +105,18 @@ export const isIgnoredPath = (ignoredPath: string | string[]) => {
return !!matchingPath;
};
let pluLock = false;
let pluTimestamp = 0;
export const pullLoggedUser = async (forceRePull = false) => {
// only pull once if not force re-pull
if (pluLock && !forceRePull) {
return;
}
// dedupe pull requests in this time span in 10 seconds
if (Date.now() - pluTimestamp < 1000 * 10) {
export const pullLoggedUser = async (isInitPull = false) => {
/**
* WARN:
* - dedupe pull requests in this time span in 10 seconds
* - isInitPull:
* Requests sent by the initialisation method cannot be throttled
* and may cause Promise.allSettled to complete early in React development mode,
* resulting in inaccurate application data.
*/
//
if (!isInitPull && Date.now() - pluTimestamp < 1000 * 10) {
return;
}
pluTimestamp = Date.now();
@ -112,7 +128,6 @@ export const pullLoggedUser = async (forceRePull = false) => {
console.error(ex);
});
if (loggedUserInfo) {
pluLock = true;
loggedUserInfoStore.getState().update(loggedUserInfo);
}
};
@ -152,7 +167,7 @@ export const activated = () => {
const us = deriveLoginState();
if (us.isNotActivated) {
gr.ok = false;
gr.redirect = RouteAlias.activation;
gr.redirect = RouteAlias.inactive;
}
return gr;
};
@ -228,16 +243,6 @@ export const allowNewRegistration = () => {
return gr;
};
export const loginAgent = () => {
const gr: TGuardResult = { ok: true };
const loginUrl = getLoginUrl();
if (loginUrl !== RouteAlias.login) {
gr.ok = false;
gr.redirect = loginUrl;
}
return gr;
};
export const singUpAgent = () => {
const gr: TGuardResult = { ok: true };
const signUpUrl = getSignUpUrl();
@ -258,19 +263,7 @@ export const shouldLoginRequired = () => {
if (us.isLogged) {
return gr;
}
if (
isIgnoredPath([
RouteAlias.login,
RouteAlias.signUp,
'/users/account-recovery',
'users/change-email',
'users/password-reset',
'users/account-activation',
'users/account-activation/success',
'/users/account-activation/failed',
'/users/confirm-new-email',
])
) {
if (isIgnoredPath(IGNORE_PATH_LIST)) {
return gr;
}
gr.ok = false;
@ -296,7 +289,7 @@ export const tryNormalLogged = (canNavigate: boolean = false) => {
return false;
}
if (us.isNotActivated) {
floppyNavigation.navigate(RouteAlias.activation);
floppyNavigation.navigate(RouteAlias.inactive);
} else if (us.isForbidden) {
floppyNavigation.navigate(RouteAlias.suspended, {
handler: 'replace',
@ -337,19 +330,21 @@ export const handleLoginWithToken = (
) => {
if (token) {
Storage.set(LOGGED_TOKEN_STORAGE_KEY, token);
getLoggedUserInfo().then((res) => {
loggedUserInfoStore.getState().update(res);
const userStat = deriveLoginState();
if (userStat.isNotActivated) {
floppyNavigation.navigate(RouteAlias.activation, {
handler,
options: {
replace: true,
},
});
} else {
handleLoginRedirect(handler);
}
setTimeout(() => {
getLoggedUserInfo().then((res) => {
loggedUserInfoStore.getState().update(res);
const userStat = deriveLoginState();
if (userStat.isNotActivated) {
floppyNavigation.navigate(RouteAlias.inactive, {
handler,
options: {
replace: true,
},
});
} else {
handleLoginRedirect(handler);
}
});
});
} else {
floppyNavigation.navigate(RouteAlias.home, {
@ -364,7 +359,6 @@ export const handleLoginWithToken = (
/**
* Initialize app configuration
*/
let appInitialized = false;
export const initAppSettingsStore = async () => {
const appSettings = await getAppSettings();
if (appSettings) {
@ -386,7 +380,13 @@ export const initAppSettingsStore = async () => {
}
};
let appInitialized = false;
export const setupApp = async () => {
/**
* This cannot be removed:
* clicking on the current navigation link will trigger a call to the routing loader,
* even though the page is not refreshed.
*/
if (appInitialized) {
return;
}
@ -395,9 +395,14 @@ export const setupApp = async () => {
* 1. must pre init logged user info for router guard
* 2. must pre init app settings for app render
*/
await Promise.allSettled([pullLoggedUser(), initAppSettingsStore()]);
await Promise.allSettled([initAppSettingsStore(), pullLoggedUser(true)]);
await Promise.allSettled([pullUcAgent()]);
setupAppLanguage();
setupAppTimeZone();
/**
* WARN:
* Initialization must be completed after all initialization actions,
* otherwise the problem of rendering twice in React development mode can lead to inaccurate data or flickering pages
*/
appInitialized = true;
};

View File

@ -3,13 +3,13 @@ import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import { Modal } from '@/components';
import { loggedUserInfoStore, toastStore, errorCodeStore } from '@/stores';
import { LOGGED_TOKEN_STORAGE_KEY, IGNORE_PATH_LIST } from '@/common/constants';
import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants';
import { RouteAlias } from '@/router/alias';
import { getCurrentLang } from '@/utils/localize';
import Storage from './storage';
import { floppyNavigation } from './floppyNavigation';
import { isIgnoredPath } from './guard';
import { isIgnoredPath, IGNORE_PATH_LIST } from './guard';
const baseConfig = {
timeout: 10000,
@ -52,19 +52,31 @@ class Request {
// no content
return true;
}
return data;
},
(error) => {
const {
status,
data: errModel,
data: errBody,
config: errConfig,
} = error.response || {};
const { data = {}, msg = '' } = errModel || {};
const { data = {}, msg = '' } = errBody || {};
const errorObject: {
code: any;
msg: string;
data: any;
// Currently only used for form errors
isError?: boolean;
// Currently only used for form errors
list?: any[];
} = {
code: status,
msg,
data,
};
if (status === 400) {
if (data?.err_type && errConfig?.passingError) {
return errModel;
return Promise.reject(errorObject);
}
if (data?.err_type) {
if (data.err_type === 'toast') {
@ -94,12 +106,9 @@ class Request {
if (data instanceof Array && data.length > 0) {
// handle form error
return Promise.reject({
code: status,
msg,
isError: true,
list: data,
});
errorObject.isError = true;
errorObject.list = data;
return Promise.reject(errorObject);
}
if (!data || Object.keys(data).length <= 0) {
@ -129,7 +138,7 @@ class Request {
}
if (data?.type === 'inactive') {
// inactivated
floppyNavigation.navigate(RouteAlias.activation);
floppyNavigation.navigate(RouteAlias.inactive);
return Promise.reject(false);
}
@ -177,7 +186,7 @@ class Request {
`Request failed with status code ${status}, ${msg || ''}`,
);
}
return Promise.reject(false);
return Promise.reject(errorObject);
},
);
}