Merge remote-tracking branch 'github/test' into release/1.0.6

# Conflicts:
#	i18n/i18n.yaml
This commit is contained in:
LinkinStars 2023-03-07 10:06:44 +08:00
commit 02b536c2cf
73 changed files with 877 additions and 230 deletions

View File

@ -1,5 +1,4 @@
// Package docs GENERATED BY SWAG; DO NOT EDIT // Code generated by swaggo/swag. DO NOT EDIT
// This file was generated by swaggo/swag
package docs package docs
import "github.com/swaggo/swag" import "github.com/swaggo/swag"
@ -5984,9 +5983,6 @@ const docTemplate = `{
"schema.GetOtherUserInfoResp": { "schema.GetOtherUserInfoResp": {
"type": "object", "type": "object",
"properties": { "properties": {
"has": {
"type": "boolean"
},
"info": { "info": {
"$ref": "#/definitions/schema.GetOtherUserInfoByUsernameResp" "$ref": "#/definitions/schema.GetOtherUserInfoByUsernameResp"
} }
@ -6986,7 +6982,11 @@ const docTemplate = `{
}, },
"user_info": { "user_info": {
"description": "user info", "description": "user info",
"$ref": "#/definitions/schema.UserBasicInfo" "allOf": [
{
"$ref": "#/definitions/schema.UserBasicInfo"
}
]
}, },
"vote_count": { "vote_count": {
"type": "integer" "type": "integer"
@ -6998,7 +6998,11 @@ const docTemplate = `{
"properties": { "properties": {
"object": { "object": {
"description": "this object", "description": "this object",
"$ref": "#/definitions/schema.SearchObject" "allOf": [
{
"$ref": "#/definitions/schema.SearchObject"
}
]
}, },
"object_type": { "object_type": {
"description": "object_type", "description": "object_type",
@ -7511,7 +7515,11 @@ const docTemplate = `{
"properties": { "properties": {
"avatar": { "avatar": {
"description": "avatar", "description": "avatar",
"$ref": "#/definitions/schema.AvatarInfo" "allOf": [
{
"$ref": "#/definitions/schema.AvatarInfo"
}
]
}, },
"bio": { "bio": {
"description": "bio", "description": "bio",
@ -7688,7 +7696,6 @@ const docTemplate = `{
], ],
"properties": { "properties": {
"status": { "status": {
"description": "user status",
"type": "string", "type": "string",
"enum": [ "enum": [
"normal", "normal",
@ -7698,7 +7705,6 @@ const docTemplate = `{
] ]
}, },
"user_id": { "user_id": {
"description": "user id",
"type": "string" "type": "string"
} }
} }

View File

@ -5972,9 +5972,6 @@
"schema.GetOtherUserInfoResp": { "schema.GetOtherUserInfoResp": {
"type": "object", "type": "object",
"properties": { "properties": {
"has": {
"type": "boolean"
},
"info": { "info": {
"$ref": "#/definitions/schema.GetOtherUserInfoByUsernameResp" "$ref": "#/definitions/schema.GetOtherUserInfoByUsernameResp"
} }
@ -6974,7 +6971,11 @@
}, },
"user_info": { "user_info": {
"description": "user info", "description": "user info",
"$ref": "#/definitions/schema.UserBasicInfo" "allOf": [
{
"$ref": "#/definitions/schema.UserBasicInfo"
}
]
}, },
"vote_count": { "vote_count": {
"type": "integer" "type": "integer"
@ -6986,7 +6987,11 @@
"properties": { "properties": {
"object": { "object": {
"description": "this object", "description": "this object",
"$ref": "#/definitions/schema.SearchObject" "allOf": [
{
"$ref": "#/definitions/schema.SearchObject"
}
]
}, },
"object_type": { "object_type": {
"description": "object_type", "description": "object_type",
@ -7499,7 +7504,11 @@
"properties": { "properties": {
"avatar": { "avatar": {
"description": "avatar", "description": "avatar",
"$ref": "#/definitions/schema.AvatarInfo" "allOf": [
{
"$ref": "#/definitions/schema.AvatarInfo"
}
]
}, },
"bio": { "bio": {
"description": "bio", "description": "bio",
@ -7676,7 +7685,6 @@
], ],
"properties": { "properties": {
"status": { "status": {
"description": "user status",
"type": "string", "type": "string",
"enum": [ "enum": [
"normal", "normal",
@ -7686,7 +7694,6 @@
] ]
}, },
"user_id": { "user_id": {
"description": "user id",
"type": "string" "type": "string"
} }
} }

View File

@ -488,8 +488,6 @@ definitions:
type: object type: object
schema.GetOtherUserInfoResp: schema.GetOtherUserInfoResp:
properties: properties:
has:
type: boolean
info: info:
$ref: '#/definitions/schema.GetOtherUserInfoByUsernameResp' $ref: '#/definitions/schema.GetOtherUserInfoByUsernameResp'
type: object type: object
@ -1201,7 +1199,8 @@ definitions:
title: title:
type: string type: string
user_info: user_info:
$ref: '#/definitions/schema.UserBasicInfo' allOf:
- $ref: '#/definitions/schema.UserBasicInfo'
description: user info description: user info
vote_count: vote_count:
type: integer type: integer
@ -1209,7 +1208,8 @@ definitions:
schema.SearchResp: schema.SearchResp:
properties: properties:
object: object:
$ref: '#/definitions/schema.SearchObject' allOf:
- $ref: '#/definitions/schema.SearchObject'
description: this object description: this object
object_type: object_type:
description: object_type description: object_type
@ -1563,7 +1563,8 @@ definitions:
schema.UpdateInfoRequest: schema.UpdateInfoRequest:
properties: properties:
avatar: avatar:
$ref: '#/definitions/schema.AvatarInfo' allOf:
- $ref: '#/definitions/schema.AvatarInfo'
description: avatar description: avatar
bio: bio:
description: bio description: bio
@ -1691,7 +1692,6 @@ definitions:
schema.UpdateUserStatusReq: schema.UpdateUserStatusReq:
properties: properties:
status: status:
description: user status
enum: enum:
- normal - normal
- suspended - suspended
@ -1699,7 +1699,6 @@ definitions:
- inactive - inactive
type: string type: string
user_id: user_id:
description: user id
type: string type: string
required: required:
- status - status

8
go.mod
View File

@ -35,13 +35,13 @@ require (
github.com/segmentfault/pacman/contrib/server/http v0.0.0-20221018072427-a15dd1434e05 github.com/segmentfault/pacman/contrib/server/http v0.0.0-20221018072427-a15dd1434e05
github.com/spf13/cobra v1.6.1 github.com/spf13/cobra v1.6.1
github.com/stretchr/testify v1.8.1 github.com/stretchr/testify v1.8.1
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a github.com/swaggo/files v1.0.0
github.com/swaggo/gin-swagger v1.5.3 github.com/swaggo/gin-swagger v1.5.3
github.com/swaggo/swag v1.8.7 github.com/swaggo/swag v1.8.10
github.com/tidwall/gjson v1.14.4 github.com/tidwall/gjson v1.14.4
github.com/yuin/goldmark v1.4.13 github.com/yuin/goldmark v1.4.13
golang.org/x/crypto v0.1.0 golang.org/x/crypto v0.1.0
golang.org/x/net v0.1.0 golang.org/x/net v0.2.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.14.2 modernc.org/sqlite v1.14.2
@ -121,7 +121,7 @@ require (
go.uber.org/zap v1.23.0 // indirect go.uber.org/zap v1.23.0 // indirect
golang.org/x/image v0.1.0 // indirect golang.org/x/image v0.1.0 // indirect
golang.org/x/mod v0.6.0 // indirect golang.org/x/mod v0.6.0 // indirect
golang.org/x/sys v0.1.0 // indirect golang.org/x/sys v0.2.0 // indirect
golang.org/x/text v0.5.0 // indirect golang.org/x/text v0.5.0 // indirect
golang.org/x/tools v0.2.0 // indirect golang.org/x/tools v0.2.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect google.golang.org/protobuf v1.28.1 // indirect

16
go.sum
View File

@ -668,13 +668,14 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowNo2MY5p6xzqtJ/wQ7LZynSvGaY=
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=
github.com/swaggo/gin-swagger v1.5.3 h1:8mWmHLolIbrhJJTflsaFoZzRBYVmEE7JZGIq08EiC0Q= github.com/swaggo/gin-swagger v1.5.3 h1:8mWmHLolIbrhJJTflsaFoZzRBYVmEE7JZGIq08EiC0Q=
github.com/swaggo/gin-swagger v1.5.3/go.mod h1:3XJKSfHjDMB5dBo/0rrTXidPmgLeqsX89Yp4uA50HpI= github.com/swaggo/gin-swagger v1.5.3/go.mod h1:3XJKSfHjDMB5dBo/0rrTXidPmgLeqsX89Yp4uA50HpI=
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/swaggo/swag v1.8.7 h1:2K9ivTD3teEO+2fXV6zrZKDqk5IuU2aJtBDo8U7omWU= github.com/swaggo/swag v1.8.10 h1:eExW4bFa52WOjqRzRD58bgWsWfdFJso50lpbeTcmTfo=
github.com/swaggo/swag v1.8.7/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk= github.com/swaggo/swag v1.8.10/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
@ -850,8 +851,8 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -944,11 +945,12 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -35,6 +35,10 @@ backend:
other: Email and password do not match. other: Email and password do not match.
error: error:
admin: admin:
cannot_update_their_password:
other: You cannot modify your password.
cannot_modify_self_status:
other: You cannot modify your status.
email_or_password_wrong: email_or_password_wrong:
other: Email and password do not match. other: Email and password do not match.
answer: answer:
@ -81,6 +85,8 @@ backend:
new_password_same_as_previous_setting: new_password_same_as_previous_setting:
other: The new password is the same as the previous one. other: The new password is the same as the previous one.
question: question:
already_deleted:
other: This post has been deleted.
not_found: not_found:
other: Question not found. other: Question not found.
cannot_deleted: cannot_deleted:
@ -819,6 +825,7 @@ ui:
approve: Approve approve: Approve
reject: Reject reject: Reject
skip: Skip skip: Skip
discard_draft: Discard draft
search: search:
title: Search Results title: Search Results
keywords: Keywords keywords: Keywords
@ -1015,9 +1022,11 @@ ui:
answers: answers answers: answers
accepted: Accepted accepted: Accepted
page_404: page_404:
http_error: HTTP Error 404
desc: "Unfortunately, this page doesn't exist." desc: "Unfortunately, this page doesn't exist."
back_home: Back to homepage back_home: Back to homepage
page_50X: page_50X:
http_error: HTTP Error 500
desc: The server encountered an error and could not complete your request. desc: The server encountered an error and could not complete your request.
back_home: Back to homepage back_home: Back to homepage
page_maintenance: page_maintenance:
@ -1407,6 +1416,10 @@ ui:
reputation: reputation reputation: reputation
votes: votes votes: votes
prompt: prompt:
leave_page: "Are you sure you want to leave the page?" leave_page: Are you sure you want to leave the page?
changes_not_save: "Your changes may not be saved." changes_not_save: Your changes may not be saved.
draft:
discard_confirm: Are you sure you want to discard your draft?
messages:
post_deleted: This post has been deleted.

View File

@ -1,28 +1,41 @@
# all support language # all support language
language_options: language_options:
- label: "English(US)" - label: "English"
value: "en_US" value: "en_US"
- label: "Español(ES)" progress: 100
- label: "Español"
value: "es_ES" value: "es_ES"
progress: 0
- label: "Português(BR)" - label: "Português(BR)"
value: "pt_BR" value: "pt_BR"
- label: "Português(PT)" progress: 0
- label: "Português"
value: "pt_PT" value: "pt_PT"
- label: "Deutsch(DE)" progress: 0
- label: "Deutsch"
value: "de_DE" value: "de_DE"
- label: "Français(FR)" progress: 4
- label: "Français"
value: "fr_FR" value: "fr_FR"
- label: "日本語(JA)" progress: 100
- label: "日本語"
value: "ja_JP" value: "ja_JP"
- label: "Italiano(IT)" progress: 0
- label: "Italiano"
value: "it_IT" value: "it_IT"
- label: "Русский(RU)" progress: 16
- label: "Русский"
value: "ru_RU" value: "ru_RU"
- label: "简体中文(CN)" progress: 13
- label: "简体中文"
value: "zh_CN" value: "zh_CN"
- label: "繁體中文(CN)" progress: 100
- label: "繁體中文"
value: "zh_TW" value: "zh_TW"
- label: "한국어(KO)" progress: 100
- label: "한국어"
value: "ko_KR" value: "ko_KR"
- label: "Tiếng Việt(VI)" progress: 0
- label: "Tiếng Việt"
value: "vi_VN" value: "vi_VN"
progress: 0

View File

@ -1,4 +1,5 @@
#The following fields are used for back-end #The following fields are used for back-end
backend: backend:
base: base:
success: success:
@ -80,6 +81,8 @@ backend:
new_password_same_as_previous_setting: new_password_same_as_previous_setting:
other: 新密码与之前的设置相同 other: 新密码与之前的设置相同
question: question:
already_deleted:
other: 该内容已被删除
not_found: not_found:
other: 问题未找到 other: 问题未找到
cannot_deleted: cannot_deleted:

View File

@ -21,6 +21,7 @@ const (
QuestionCannotDeleted = "error.question.cannot_deleted" QuestionCannotDeleted = "error.question.cannot_deleted"
QuestionCannotClose = "error.question.cannot_close" QuestionCannotClose = "error.question.cannot_close"
QuestionCannotUpdate = "error.question.cannot_update" QuestionCannotUpdate = "error.question.cannot_update"
QuestionAlreadyDeleted = "error.question.already_deleted"
AnswerNotFound = "error.answer.not_found" AnswerNotFound = "error.answer.not_found"
AnswerCannotDeleted = "error.answer.cannot_deleted" AnswerCannotDeleted = "error.answer.cannot_deleted"
AnswerCannotUpdate = "error.answer.cannot_update" AnswerCannotUpdate = "error.answer.cannot_update"
@ -64,4 +65,6 @@ const (
TagCannotSetSynonymAsItself = "error.tag.cannot_set_synonym_as_itself" TagCannotSetSynonymAsItself = "error.tag.cannot_set_synonym_as_itself"
NotAllowedRegistration = "error.user.not_allowed_registration" NotAllowedRegistration = "error.user.not_allowed_registration"
SMTPConfigFromNameCannotBeEmail = "error.smtp.config_from_name_cannot_be_email" SMTPConfigFromNameCannotBeEmail = "error.smtp.config_from_name_cannot_be_email"
AdminCannotUpdateTheirPassword = "error.admin.cannot_update_their_password"
AdminCannotModifySelfStatus = "error.admin.cannot_modify_self_status"
) )

View File

@ -20,6 +20,8 @@ var GlobalTrans i18n.Translator
type LangOption struct { type LangOption struct {
Label string `json:"label"` Label string `json:"label"`
Value string `json:"value"` Value string `json:"value"`
// Translation completion percentage
Progress int `json:"progress"`
} }
// DefaultLangOption default language option. If user config the language is default, the language option is admin choose. // DefaultLangOption default language option. If user config the language is default, the language option is admin choose.
@ -94,6 +96,11 @@ func NewTranslator(c *I18n) (tr i18n.Translator, err error) {
return nil, fmt.Errorf("i18n file parsing failed: %s", err) return nil, fmt.Errorf("i18n file parsing failed: %s", err)
} }
LanguageOptions = s.LangOption LanguageOptions = s.LangOption
for _, option := range LanguageOptions {
if option.Progress != 100 {
option.Label = fmt.Sprintf("%s (%d%%)", option.Label, option.Progress)
}
}
return GlobalTrans, err return GlobalTrans, err
} }

View File

@ -112,6 +112,18 @@ func (cc *CommentController) UpdateComment(ctx *gin.Context) {
req.UserID = middleware.GetLoginUserIDFromContext(ctx) req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetIsAdminFromContext(ctx) req.IsAdmin = middleware.GetIsAdminFromContext(ctx)
canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.CommentAdd,
permission.CommentEdit,
permission.CommentDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanAdd = canList[0]
req.CanEdit = canList[1]
req.CanDelete = canList[2]
can, err := cc.rankService.CheckOperationPermission(ctx, req.UserID, permission.CommentEdit, req.CommentID) can, err := cc.rankService.CheckOperationPermission(ctx, req.UserID, permission.CommentEdit, req.CommentID)
if err != nil { if err != nil {
handler.HandleResponse(ctx, err, nil) handler.HandleResponse(ctx, err, nil)
@ -122,8 +134,8 @@ func (cc *CommentController) UpdateComment(ctx *gin.Context) {
return return
} }
err = cc.commentService.UpdateComment(ctx, req) resp, err := cc.commentService.UpdateComment(ctx, req)
handler.HandleResponse(ctx, err, nil) handler.HandleResponse(ctx, err, resp)
} }
// GetCommentWithPage get comment page // GetCommentWithPage get comment page

View File

@ -404,22 +404,17 @@ func (tc *TemplateController) UserInfo(ctx *gin.Context) {
req := &schema.GetOtherUserInfoByUsernameReq{} req := &schema.GetOtherUserInfoByUsernameReq{}
req.Username = username req.Username = username
userinfo, err := tc.templateRenderController.UserInfo(ctx, req) userinfo, err := tc.templateRenderController.UserInfo(ctx, req)
if err != nil { if err != nil {
tc.Page404(ctx) tc.Page404(ctx)
return return
} }
if !userinfo.Has {
tc.Page404(ctx)
return
}
siteInfo := tc.SiteInfo(ctx) siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/users/%s", siteInfo.General.SiteUrl, username) siteInfo.Canonical = fmt.Sprintf("%s/users/%s", siteInfo.General.SiteUrl, username)
siteInfo.Title = fmt.Sprintf("%s - %s", username, siteInfo.General.Name) siteInfo.Title = fmt.Sprintf("%s - %s", username, siteInfo.General.Name)
tc.html(ctx, http.StatusOK, "homepage.html", siteInfo, gin.H{ tc.html(ctx, http.StatusOK, "homepage.html", siteInfo, gin.H{
"userinfo": userinfo, "userinfo": userinfo,
"bio": template.HTML(userinfo.Info.BioHTML), "bio": template.HTML(userinfo.BioHTML),
}) })
} }
@ -451,6 +446,7 @@ func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteI
if !ok { if !ok {
data["path"] = "" data["path"] = ""
} }
ctx.Header("X-Frame-Options", "DENY")
ctx.HTML(code, tpl, data) ctx.HTML(code, tpl, data)
} }

View File

@ -5,6 +5,6 @@ import (
"golang.org/x/net/context" "golang.org/x/net/context"
) )
func (q *TemplateRenderController) UserInfo(ctx context.Context, req *schema.GetOtherUserInfoByUsernameReq) (resp *schema.GetOtherUserInfoResp, err error) { func (q *TemplateRenderController) UserInfo(ctx context.Context, req *schema.GetOtherUserInfoByUsernameReq) (resp *schema.GetOtherUserInfoByUsernameResp, err error) {
return q.userService.GetOtherUserInfoByUsername(ctx, req.Username) return q.userService.GetOtherUserInfoByUsername(ctx, req.Username)
} }

View File

@ -157,7 +157,7 @@ func (uc *UserController) RetrievePassWord(ctx *gin.Context) {
return return
} }
_, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeFindPass, ctx.ClientIP()) _, _ = uc.actionService.ActionRecordAdd(ctx, schema.ActionRecordTypeFindPass, ctx.ClientIP())
_, err := uc.userService.RetrievePassWord(ctx, req) err := uc.userService.RetrievePassWord(ctx, req)
handler.HandleResponse(ctx, err, nil) handler.HandleResponse(ctx, err, nil)
} }
@ -203,6 +203,7 @@ func (uc *UserController) UserLogout(ctx *gin.Context) {
return return
} }
_ = uc.authService.RemoveUserCacheInfo(ctx, accessToken) _ = uc.authService.RemoveUserCacheInfo(ctx, accessToken)
_ = uc.authService.RemoveAdminUserCacheInfo(ctx, accessToken)
handler.HandleResponse(ctx, nil, nil) handler.HandleResponse(ctx, nil, nil)
} }

View File

@ -34,6 +34,8 @@ func (uc *UserAdminController) UpdateUserStatus(ctx *gin.Context) {
return return
} }
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
err := uc.userService.UpdateUserStatus(ctx, req) err := uc.userService.UpdateUserStatus(ctx, req)
handler.HandleResponse(ctx, err, nil) handler.HandleResponse(ctx, err, nil)
} }

View File

@ -35,9 +35,11 @@ type Answer struct {
type AnswerSearch struct { type AnswerSearch struct {
Answer Answer
Order string `json:"order_by" ` // default or updated IncludeDeleted bool `json:"include_deleted"`
Page int `json:"page" form:"page"` // Query number of pages LoginUserID string `json:"login_user_id"`
PageSize int `json:"page_size" form:"page_size"` // Search page size Order string `json:"order_by"` // default or updated
Page int `json:"page" form:"page"` // Query number of pages
PageSize int `json:"page_size" form:"page_size"` // Search page size
} }
type AdminAnswerSearch struct { type AdminAnswerSearch struct {

View File

@ -78,7 +78,7 @@ type InitEnvironmentResp struct {
// InitBaseInfoReq init base info request // InitBaseInfoReq init base info request
type InitBaseInfoReq struct { type InitBaseInfoReq struct {
Language string `validate:"required,gt=0,lte=30" json:"lang"` Language string `validate:"required,gt=0,lte=30" json:"lang"`
SiteName string `validate:"required,gt=0,lte=30" json:"site_name"` SiteName string `validate:"required,sanitizer,gt=0,lte=30" json:"site_name"`
SiteURL string `validate:"required,gt=0,lte=512,url" json:"site_url"` SiteURL string `validate:"required,gt=0,lte=512,url" json:"site_url"`
ContactEmail string `validate:"required,email,gt=0,lte=500" json:"contact_email"` ContactEmail string `validate:"required,email,gt=0,lte=500" json:"contact_email"`
AdminName string `validate:"required,gt=3,lte=30" json:"name"` AdminName string `validate:"required,gt=3,lte=30" json:"name"`

View File

@ -206,7 +206,9 @@ func (ar *answerRepo) SearchList(ctx context.Context, search *entity.AnswerSearc
default: default:
session = session.OrderBy("adopted desc,vote_count desc,created_at asc") session = session.OrderBy("adopted desc,vote_count desc,created_at asc")
} }
session = session.And("status = ?", entity.AnswerStatusAvailable) if !search.IncludeDeleted {
session = session.And("status = ? OR user_id = ?", entity.AnswerStatusAvailable, search.LoginUserID)
}
session = session.Limit(search.PageSize, offset) session = session.Limit(search.PageSize, offset)
count, err = session.FindAndCount(&rows) count, err = session.FindAndCount(&rows)

View File

@ -68,3 +68,11 @@ func (cr *captchaRepo) GetCaptcha(ctx context.Context, key string) (captcha stri
} }
return captcha, nil return captcha, nil
} }
func (cr *captchaRepo) DelCaptcha(ctx context.Context, key string) (err error) {
err = cr.data.Cache.Del(ctx, key)
if err != nil {
log.Debug(err)
}
return nil
}

View File

@ -106,6 +106,7 @@ func (a *UIRouter) Register(r *gin.Engine) {
default: default:
filePath = UIIndexFilePath filePath = UIIndexFilePath
c.Header("content-type", "text/html;charset=utf-8") c.Header("content-type", "text/html;charset=utf-8")
c.Header("X-Frame-Options", "DENY")
} }
file, err := ui.Build.ReadFile(filePath) file, err := ui.Build.ReadFile(filePath)
if err != nil { if err != nil {

View File

@ -83,6 +83,7 @@ type AnswerInfo struct {
VoteStatus string `json:"vote_status"` VoteStatus string `json:"vote_status"`
VoteCount int `json:"vote_count"` VoteCount int `json:"vote_count"`
QuestionInfo *QuestionInfo `json:"question_info,omitempty"` QuestionInfo *QuestionInfo `json:"question_info,omitempty"`
Status int `json:"status"`
// MemberActions // MemberActions
MemberActions []*PermissionMemberAction `json:"member_actions"` MemberActions []*PermissionMemberAction `json:"member_actions"`

View File

@ -2,10 +2,9 @@ package schema
// UpdateUserStatusReq update user request // UpdateUserStatusReq update user request
type UpdateUserStatusReq struct { type UpdateUserStatusReq struct {
// user id UserID string `validate:"required" json:"user_id"`
UserID string `validate:"required" json:"user_id"` Status string `validate:"required,oneof=normal suspended deleted inactive" json:"status" enums:"normal,suspended,deleted,inactive"`
// user status LoginUserID string `json:"-"`
Status string `validate:"required,oneof=normal suspended deleted inactive" json:"status" enums:"normal,suspended,deleted,inactive"`
} }
const ( const (

View File

@ -53,6 +53,12 @@ type UpdateCommentReq struct {
// user id // user id
UserID string `json:"-"` UserID string `json:"-"`
IsAdmin bool `json:"-"` IsAdmin bool `json:"-"`
CanAdd bool `json:"-"`
// whether user can edit it
CanEdit bool `json:"-"`
// whether user can delete it
CanDelete bool `json:"-"`
} }
func (req *UpdateCommentReq) Check() (errFields []*validator.FormErrorField, err error) { func (req *UpdateCommentReq) Check() (errFields []*validator.FormErrorField, err error) {

View File

@ -176,11 +176,20 @@ type AdminQuestionInfo struct {
UserInfo *UserBasicInfo `json:"user_info"` UserInfo *UserBasicInfo `json:"user_info"`
} }
type OperationLevel string
const (
OperationLevelInfo OperationLevel = "info"
OperationLevelDanger OperationLevel = "danger"
OperationLevelWarning OperationLevel = "warning"
)
type Operation struct { type Operation struct {
OperationType string `json:"operation_type"` Type string `json:"type"`
OperationDescription string `json:"operation_description"` Description string `json:"description"`
OperationMsg string `json:"operation_msg"` Msg string `json:"msg"`
OperationTime int64 `json:"operation_time"` Time int64 `json:"time"`
Level OperationLevel `json:"level"`
} }
type GetCloseTypeResp struct { type GetCloseTypeResp struct {

View File

@ -90,7 +90,7 @@ type GetTagResp struct {
} }
func (tr *GetTagResp) GetExcerpt() { func (tr *GetTagResp) GetExcerpt() {
excerpt := strings.TrimSpace(tr.OriginalText) excerpt := strings.TrimSpace(tr.ParsedText)
idx := strings.Index(excerpt, "\n") idx := strings.Index(excerpt, "\n")
if idx >= 0 { if idx >= 0 {
excerpt = excerpt[0:idx] excerpt = excerpt[0:idx]

View File

@ -309,7 +309,7 @@ func (req *UpdateInfoRequest) Check() (errFields []*validator.FormErrorField, er
return errFields, errors.BadRequest(reason.UsernameInvalid) return errFields, errors.BadRequest(reason.UsernameInvalid)
} }
} }
req.BioHTML = converter.Markdown2HTML(req.Bio) req.BioHTML = converter.Markdown2BasicHTML(req.Bio)
return nil, nil return nil, nil
} }
@ -386,7 +386,6 @@ type GetOtherUserInfoByUsernameReq struct {
type GetOtherUserInfoResp struct { type GetOtherUserInfoResp struct {
Info *GetOtherUserInfoByUsernameResp `json:"info"` Info *GetOtherUserInfoByUsernameResp `json:"info"`
Has bool `json:"has"`
} }
type UserChangeEmailSendCodeReq struct { type UserChangeEmailSendCodeReq struct {

View File

@ -16,6 +16,7 @@ import (
type CaptchaRepo interface { type CaptchaRepo interface {
SetCaptcha(ctx context.Context, key, captcha string) (err error) SetCaptcha(ctx context.Context, key, captcha string) (err error)
GetCaptcha(ctx context.Context, key string) (captcha string, err error) GetCaptcha(ctx context.Context, key string) (captcha string, err error)
DelCaptcha(ctx context.Context, key string) (err error)
SetActionType(ctx context.Context, ip, actionType string, amount int) (err error) SetActionType(ctx context.Context, ip, actionType string, amount int) (err error)
GetActionType(ctx context.Context, ip, actionType string) (amount int, err error) GetActionType(ctx context.Context, ip, actionType string) (amount int, err error)
DelActionType(ctx context.Context, ip, actionType string) (err error) DelActionType(ctx context.Context, ip, actionType string) (err error)
@ -143,6 +144,12 @@ func (cs *CaptchaService) GenerateCaptcha(ctx context.Context) (key, captchaBase
func (cs *CaptchaService) VerifyCaptcha(ctx context.Context, key, captcha string) (isCorrect bool, err error) { func (cs *CaptchaService) VerifyCaptcha(ctx context.Context, key, captcha string) (isCorrect bool, err error) {
realCaptcha, err := cs.captchaRepo.GetCaptcha(ctx, key) realCaptcha, err := cs.captchaRepo.GetCaptcha(ctx, key)
if err != nil { if err != nil {
log.Error("VerifyCaptcha GetCaptcha Error", err.Error())
return false, nil
}
err = cs.captchaRepo.DelCaptcha(ctx, key)
if err != nil {
log.Error("VerifyCaptcha DelCaptcha Error", err.Error())
return false, nil return false, nil
} }
return strings.TrimSpace(captcha) == realCaptcha, nil return strings.TrimSpace(captcha) == realCaptcha, nil

View File

@ -73,6 +73,7 @@ func (as *AnswerCommon) ShowFormat(ctx context.Context, data *entity.Answer) *sc
} }
info.UserID = data.UserID info.UserID = data.UserID
info.UpdateUserID = data.LastEditUserID info.UpdateUserID = data.LastEditUserID
info.Status = data.Status
return &info return &info
} }

View File

@ -164,7 +164,7 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) (
if err != nil { if err != nil {
log.Error("UpdateLastAnswer error", err.Error()) log.Error("UpdateLastAnswer error", err.Error())
} }
err = as.questionCommon.UpdataPostTime(ctx, req.QuestionID) err = as.questionCommon.UpdatePostTime(ctx, req.QuestionID)
if err != nil { if err != nil {
return insertData.ID, err return insertData.ID, err
} }
@ -232,6 +232,11 @@ func (as *AnswerService) Update(ctx context.Context, req *schema.AnswerUpdateReq
return "", nil return "", nil
} }
if answerInfo.Status == entity.AnswerStatusDeleted {
err = errors.BadRequest(reason.AnswerCannotUpdate)
return "", err
}
//If the content is the same, ignore it //If the content is the same, ignore it
if answerInfo.OriginalText == req.Content { if answerInfo.OriginalText == req.Content {
return "", nil return "", nil
@ -268,7 +273,7 @@ func (as *AnswerService) Update(ctx context.Context, req *schema.AnswerUpdateReq
if err = as.answerRepo.UpdateAnswer(ctx, insertData, []string{"original_text", "parsed_text", "updated_at", "last_edit_user_id"}); err != nil { if err = as.answerRepo.UpdateAnswer(ctx, insertData, []string{"original_text", "parsed_text", "updated_at", "last_edit_user_id"}); err != nil {
return "", err return "", err
} }
err = as.questionCommon.UpdataPostTime(ctx, req.QuestionID) err = as.questionCommon.UpdatePostTime(ctx, req.QuestionID)
if err != nil { if err != nil {
return insertData.ID, err return insertData.ID, err
} }
@ -473,6 +478,8 @@ func (as *AnswerService) SearchList(ctx context.Context, req *schema.AnswerListR
dbSearch.Page = req.Page dbSearch.Page = req.Page
dbSearch.PageSize = req.PageSize dbSearch.PageSize = req.PageSize
dbSearch.Order = req.Order dbSearch.Order = req.Order
dbSearch.IncludeDeleted = req.CanDelete
dbSearch.LoginUserID = req.UserID
answerOriginalList, count, err := as.answerRepo.SearchList(ctx, &dbSearch) answerOriginalList, count, err := as.answerRepo.SearchList(ctx, &dbSearch)
if err != nil { if err != nil {
return list, count, err return list, count, err

View File

@ -209,24 +209,40 @@ func (cs *CommentService) RemoveComment(ctx context.Context, req *schema.RemoveC
} }
// UpdateComment update comment // UpdateComment update comment
func (cs *CommentService) UpdateComment(ctx context.Context, req *schema.UpdateCommentReq) (err error) { func (cs *CommentService) UpdateComment(ctx context.Context, req *schema.UpdateCommentReq) (
resp *schema.GetCommentResp, err error) {
resp = &schema.GetCommentResp{}
old, exist, err := cs.commentCommonRepo.GetComment(ctx, req.CommentID) old, exist, err := cs.commentCommonRepo.GetComment(ctx, req.CommentID)
if err != nil { if err != nil {
return return
} }
if !exist { if !exist {
return errors.BadRequest(reason.CommentNotFound) return resp, errors.BadRequest(reason.CommentNotFound)
} }
// user can edit the comment that was posted by himself before deadline. // user can edit the comment that was posted by himself before deadline.
if !req.IsAdmin && (time.Now().After(old.CreatedAt.Add(constant.CommentEditDeadline))) { if !req.IsAdmin && (time.Now().After(old.CreatedAt.Add(constant.CommentEditDeadline))) {
return errors.BadRequest(reason.CommentCannotEditAfterDeadline) return resp, errors.BadRequest(reason.CommentCannotEditAfterDeadline)
} }
comment := &entity.Comment{} comment := &entity.Comment{}
_ = copier.Copy(comment, req) _ = copier.Copy(comment, req)
comment.ID = req.CommentID comment.ID = req.CommentID
return cs.commentRepo.UpdateComment(ctx, comment) resp.SetFromComment(comment)
resp.MemberActions = permission.GetCommentPermission(ctx, req.UserID, resp.UserID,
time.Now(), req.CanEdit, req.CanDelete)
userInfo, exist, err := cs.userCommon.GetUserBasicInfoByID(ctx, resp.UserID)
if err != nil {
return nil, err
}
if exist {
resp.Username = userInfo.Username
resp.UserDisplayName = userInfo.DisplayName
resp.UserAvatar = userInfo.Avatar
resp.UserStatus = userInfo.Status
}
return resp, cs.commentRepo.UpdateComment(ctx, comment)
} }
// GetComment get comment one // GetComment get comment one

View File

@ -89,6 +89,7 @@ func (ds *DashboardService) StatisticalByCache(ctx context.Context) (*schema.Das
} }
startTime := time.Now().Unix() - schema.AppStartTime.Unix() startTime := time.Now().Unix() - schema.AppStartTime.Unix()
dashboardInfo.AppStartTime = fmt.Sprintf("%d", startTime) dashboardInfo.AppStartTime = fmt.Sprintf("%d", startTime)
dashboardInfo.VersionInfo.Version = constant.Version
return dashboardInfo, nil return dashboardInfo, nil
} }

View File

@ -112,14 +112,14 @@ func (qs *QuestionCommon) UpdateLastAnswer(ctx context.Context, questionID, Answ
return qs.questionRepo.UpdateLastAnswer(ctx, question) return qs.questionRepo.UpdateLastAnswer(ctx, question)
} }
func (qs *QuestionCommon) UpdataPostTime(ctx context.Context, questionID string) error { func (qs *QuestionCommon) UpdatePostTime(ctx context.Context, questionID string) error {
questioninfo := &entity.Question{} questioninfo := &entity.Question{}
now := time.Now() now := time.Now()
questioninfo.ID = questionID questioninfo.ID = questionID
questioninfo.PostUpdateTime = now questioninfo.PostUpdateTime = now
return qs.questionRepo.UpdateQuestion(ctx, questioninfo, []string{"post_update_time"}) return qs.questionRepo.UpdateQuestion(ctx, questioninfo, []string{"post_update_time"})
} }
func (qs *QuestionCommon) UpdataPostSetTime(ctx context.Context, questionID string, setTime time.Time) error { func (qs *QuestionCommon) UpdatePostSetTime(ctx context.Context, questionID string, setTime time.Time) error {
questioninfo := &entity.Question{} questioninfo := &entity.Question{}
questioninfo.ID = questionID questioninfo.ID = questionID
questioninfo.PostUpdateTime = setTime questioninfo.PostUpdateTime = setTime
@ -148,7 +148,7 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
return showinfo, err return showinfo, err
} }
if !has { if !has {
return showinfo, errors.BadRequest(reason.QuestionNotFound) return showinfo, errors.NotFound(reason.QuestionNotFound)
} }
showinfo = qs.ShowFormat(ctx, dbinfo) showinfo = qs.ShowFormat(ctx, dbinfo)
@ -170,10 +170,11 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
log.Error("json.Unmarshal QuestionCloseJson error", err.Error()) log.Error("json.Unmarshal QuestionCloseJson error", err.Error())
} else { } else {
operation := &schema.Operation{} operation := &schema.Operation{}
operation.OperationType = closeinfo.Name operation.Type = closeinfo.Name
operation.OperationDescription = closeinfo.Description operation.Description = closeinfo.Description
operation.OperationMsg = closemsg.CloseMsg operation.Msg = closemsg.CloseMsg
operation.OperationTime = metainfo.CreatedAt.Unix() operation.Time = metainfo.CreatedAt.Unix()
operation.Level = schema.OperationLevelInfo
showinfo.Operation = operation showinfo.Operation = operation
} }

View File

@ -470,6 +470,10 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest
if !has { if !has {
return return
} }
if dbinfo.Status == entity.QuestionStatusDeleted {
err = errors.BadRequest(reason.QuestionCannotUpdate)
return nil, err
}
now := time.Now() now := time.Now()
question := &entity.Question{} question := &entity.Question{}
@ -614,12 +618,23 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
if err != nil { if err != nil {
return return
} }
// If the question is deleted, only the administrator and the author can view it
if question.Status == entity.QuestionStatusDeleted && !per.CanReopen && question.UserID != userID {
return nil, errors.NotFound(reason.QuestionNotFound)
}
if question.Status != entity.QuestionStatusClosed { if question.Status != entity.QuestionStatusClosed {
per.CanReopen = false per.CanReopen = false
} }
if question.Status == entity.QuestionStatusClosed { if question.Status == entity.QuestionStatusClosed {
per.CanClose = false per.CanClose = false
} }
if question.Status == entity.QuestionStatusDeleted {
operation := &schema.Operation{}
operation.Msg = translator.Tr(handler.GetLangByCtx(ctx), reason.QuestionAlreadyDeleted)
operation.Level = schema.OperationLevelDanger
question.Operation = operation
}
question.Description = htmltext.FetchExcerpt(question.HTML, "...", 240) question.Description = htmltext.FetchExcerpt(question.HTML, "...", 240)
question.MemberActions = permission.GetQuestionPermission(ctx, userID, question.UserID, question.MemberActions = permission.GetQuestionPermission(ctx, userID, question.UserID,
per.CanEdit, per.CanDelete, per.CanClose, per.CanReopen) per.CanEdit, per.CanDelete, per.CanClose, per.CanReopen)

View File

@ -191,7 +191,7 @@ func (rs *RevisionService) revisionAuditAnswer(ctx context.Context, revisionitem
if saveerr != nil { if saveerr != nil {
return saveerr return saveerr
} }
saveerr = rs.questionCommon.UpdataPostSetTime(ctx, answerinfo.QuestionID, PostUpdateTime) saveerr = rs.questionCommon.UpdatePostSetTime(ctx, answerinfo.QuestionID, PostUpdateTime)
if saveerr != nil { if saveerr != nil {
return saveerr return saveerr
} }

View File

@ -102,7 +102,7 @@ func (ts *TagService) GetTagInfo(ctx context.Context, req *schema.GetTagInfoReq)
return nil, err return nil, err
} }
if !exist { if !exist {
return nil, errors.BadRequest(reason.TagNotFound) return nil, errors.NotFound(reason.TagNotFound)
} }
resp = &schema.GetTagResp{} resp = &schema.GetTagResp{}
@ -113,7 +113,7 @@ func (ts *TagService) GetTagInfo(ctx context.Context, req *schema.GetTagInfoReq)
return nil, err return nil, err
} }
if !exist { if !exist {
return nil, errors.BadRequest(reason.TagNotFound) return nil, errors.NotFound(reason.TagNotFound)
} }
resp.MainTagSlugName = tagInfo.SlugName resp.MainTagSlugName = tagInfo.SlugName
} }

View File

@ -61,6 +61,10 @@ func NewUserAdminService(
// UpdateUserStatus update user // UpdateUserStatus update user
func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.UpdateUserStatusReq) (err error) { func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.UpdateUserStatusReq) (err error) {
// Admin cannot modify their status
if req.UserID == req.LoginUserID {
return errors.BadRequest(reason.AdminCannotModifySelfStatus)
}
userInfo, exist, err := us.userRepo.GetUserInfo(ctx, req.UserID) userInfo, exist, err := us.userRepo.GetUserInfo(ctx, req.UserID)
if err != nil { if err != nil {
return return
@ -153,6 +157,10 @@ func (us *UserAdminService) AddUser(ctx context.Context, req *schema.AddUserReq)
// UpdateUserPassword update user password // UpdateUserPassword update user password
func (us *UserAdminService) UpdateUserPassword(ctx context.Context, req *schema.UpdateUserPasswordReq) (err error) { func (us *UserAdminService) UpdateUserPassword(ctx context.Context, req *schema.UpdateUserPasswordReq) (err error) {
// Users cannot modify their password
if req.UserID == req.LoginUserID {
return errors.BadRequest(reason.AdminCannotUpdateTheirPassword)
}
userInfo, exist, err := us.userRepo.GetUserInfo(ctx, req.UserID) userInfo, exist, err := us.userRepo.GetUserInfo(ctx, req.UserID)
if err != nil { if err != nil {
return err return err

View File

@ -86,19 +86,17 @@ func (us *UserService) GetUserInfoByUserID(ctx context.Context, token, userID st
} }
func (us *UserService) GetOtherUserInfoByUsername(ctx context.Context, username string) ( func (us *UserService) GetOtherUserInfoByUsername(ctx context.Context, username string) (
resp *schema.GetOtherUserInfoResp, err error, resp *schema.GetOtherUserInfoByUsernameResp, err error,
) { ) {
userInfo, exist, err := us.userRepo.GetByUsername(ctx, username) userInfo, exist, err := us.userRepo.GetByUsername(ctx, username)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp = &schema.GetOtherUserInfoResp{}
if !exist { if !exist {
return resp, nil return nil, errors.NotFound(reason.UserNotFound)
} }
resp.Has = true resp = &schema.GetOtherUserInfoByUsernameResp{}
resp.Info = &schema.GetOtherUserInfoByUsernameResp{} resp.GetFromUserEntity(userInfo)
resp.Info.GetFromUserEntity(userInfo)
return resp, nil return resp, nil
} }
@ -149,13 +147,13 @@ func (us *UserService) EmailLogin(ctx context.Context, req *schema.UserEmailLogi
} }
// RetrievePassWord . // RetrievePassWord .
func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRetrievePassWordRequest) (string, error) { func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRetrievePassWordRequest) error {
userInfo, has, err := us.userRepo.GetByEmail(ctx, req.Email) userInfo, has, err := us.userRepo.GetByEmail(ctx, req.Email)
if err != nil { if err != nil {
return "", err return err
} }
if !has { if !has {
return "", errors.BadRequest(reason.UserNotFound) return nil
} }
// send email // send email
@ -167,10 +165,10 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet
verifyEmailURL := fmt.Sprintf("%s/users/password-reset?code=%s", us.getSiteUrl(ctx), code) verifyEmailURL := fmt.Sprintf("%s/users/password-reset?code=%s", us.getSiteUrl(ctx), code)
title, body, err := us.emailService.PassResetTemplate(ctx, verifyEmailURL) title, body, err := us.emailService.PassResetTemplate(ctx, verifyEmailURL)
if err != nil { if err != nil {
return "", err return err
} }
go us.emailService.SendAndSaveCode(ctx, req.Email, title, body, code, data.ToJSONString()) go us.emailService.SendAndSaveCode(ctx, req.Email, title, body, code, data.ToJSONString())
return code, nil return nil
} }
// UseRePassword // UseRePassword

View File

@ -35,6 +35,17 @@ func Markdown2HTML(source string) string {
return buf.String() return buf.String()
} }
// Markdown2BasicHTML convert markdown to html ,Only basic syntax can be used
func Markdown2BasicHTML(source string) string {
content := Markdown2HTML(source)
filter := bluemonday.NewPolicy()
filter.AllowElements("p", "b", "br")
filter.AllowAttrs("src").OnElements("img")
filter.AddSpaceWhenStrippingTag(true)
content = filter.Sanitize(content)
return content
}
type DangerousHTMLFilterExtension struct { type DangerousHTMLFilterExtension struct {
} }

View File

@ -6,6 +6,21 @@ export const LOGGED_USER_STORAGE_KEY = '_a_lui_';
export const LOGGED_TOKEN_STORAGE_KEY = '_a_ltk_'; export const LOGGED_TOKEN_STORAGE_KEY = '_a_ltk_';
export const REDIRECT_PATH_STORAGE_KEY = '_a_rp_'; export const REDIRECT_PATH_STORAGE_KEY = '_a_rp_';
export const CAPTCHA_CODE_STORAGE_KEY = '_a_captcha_'; export const CAPTCHA_CODE_STORAGE_KEY = '_a_captcha_';
export const DRAFT_QUESTION_STORAGE_KEY = '_a_dq_';
export const DRAFT_ANSWER_STORAGE_KEY = '_a_da_';
export const DRAFT_TIMESIGH_STORAGE_KEY = '|_a_t_s_|';
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 = { export const ADMIN_LIST_STATUS = {
// normal; // normal;

View File

@ -5,7 +5,6 @@ import { Link } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { unionBy } from 'lodash'; import { unionBy } from 'lodash';
import { marked } from 'marked';
import * as Types from '@/common/interface'; import * as Types from '@/common/interface';
import { Modal } from '@/components'; import { Modal } from '@/components';
@ -108,15 +107,11 @@ const Comment = ({ objectId, mode, commentId }) => {
const users = matchedUsers(item.value); const users = matchedUsers(item.value);
const userNames = unionBy(users.map((user) => user.userName)); const userNames = unionBy(users.map((user) => user.userName));
const commentMarkDown = parseUserInfo(item.value); const commentMarkDown = parseUserInfo(item.value);
const html = marked.parse(commentMarkDown);
// if (!commentMarkDown || !html) {
// return;
// }
const params = { const params = {
object_id: objectId, object_id: objectId,
original_text: commentMarkDown, original_text: commentMarkDown,
mention_username_list: userNames, mention_username_list: userNames,
parsed_text: html,
...(item.type === 'reply' ...(item.type === 'reply'
? { ? {
reply_comment_id: item.comment_id, reply_comment_id: item.comment_id,
@ -128,13 +123,13 @@ const Comment = ({ objectId, mode, commentId }) => {
return updateComment({ return updateComment({
...params, ...params,
comment_id: item.comment_id, comment_id: item.comment_id,
}).then(() => { }).then((res) => {
setComments( setComments(
comments.map((comment) => { comments.map((comment) => {
if (comment.comment_id === item.comment_id) { if (comment.comment_id === item.comment_id) {
comment.showEdit = false; comment.showEdit = false;
comment.parsed_text = html; comment.parsed_text = res.parsed_text;
comment.original_text = item.value; comment.original_text = res.original_text;
} }
return comment; return comment;
}), }),

View File

@ -69,7 +69,10 @@ const Index: FC<Props> = ({
{objectType !== 'answer' && opts?.showTitle && ( {objectType !== 'answer' && opts?.showTitle && (
<h5 <h5
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: diffText(newData.title, oldData?.title), __html: diffText(
newData.title?.replace(/</gi, '&lt;'),
oldData?.title?.replace(/</gi, '&lt;'),
),
}} }}
className="mb-3" className="mb-3"
/> />

View File

@ -114,19 +114,8 @@ export function htmlRender(el: HTMLElement | null) {
}, },
); );
el.querySelectorAll('table').forEach((table) => { // remove change table style to htmlToReact function
if ( /**
(table.parentNode as HTMLDivElement)?.classList.contains( * @description: You modify the DOM with other scripts after React has rendered the DOM. This way, on the next render cycle (re-render), React cannot find the DOM node it rendered before, because it has been modified or removed by other scripts.
'table-responsive', */
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
} }

View File

@ -71,6 +71,10 @@ const Header: FC = () => {
}; };
const onLoginClick = (evt) => { const onLoginClick = (evt) => {
evt.preventDefault(); evt.preventDefault();
if (location.pathname === '/users/login') {
window.location.reload();
return;
}
floppyNavigation.navigateToLogin((loginPath) => { floppyNavigation.navigateToLogin((loginPath) => {
navigate(loginPath, { replace: true }); navigate(loginPath, { replace: true });
}); });

View File

@ -206,7 +206,6 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
const errors = requiredValidator(); const errors = requiredValidator();
if (errors.length > 0) { if (errors.length > 0) {
formData = errors.reduce((acc, cur) => { formData = errors.reduce((acc, cur) => {
console.log('schema.properties[cur]', cur);
acc[cur] = { acc[cur] = {
...formData[cur], ...formData[cur],
isInvalid: true, isInvalid: true,

View File

@ -9,6 +9,7 @@ import useUserModal from './useUserModal';
import useChangePasswordModal from './useChangePasswordModal'; import useChangePasswordModal from './useChangePasswordModal';
import usePageTags from './usePageTags'; import usePageTags from './usePageTags';
import usePromptWithUnload from './usePrompt'; import usePromptWithUnload from './usePrompt';
import useImgViewer from './useImgViewer';
export { export {
useTagModal, useTagModal,
@ -22,4 +23,5 @@ export {
useChangePasswordModal, useChangePasswordModal,
usePageTags, usePageTags,
usePromptWithUnload, usePromptWithUnload,
useImgViewer,
}; };

View File

@ -0,0 +1,78 @@
import { useLayoutEffect, useState, MouseEvent, useEffect } from 'react';
import { Modal } from 'react-bootstrap';
import { useLocation } from 'react-router-dom';
import ReactDOM from 'react-dom/client';
const div = document.createElement('div');
const root = ReactDOM.createRoot(div);
const useImgViewer = () => {
const location = useLocation();
const [visible, setVisible] = useState(false);
const [imgSrc, setImgSrc] = useState('');
const onClose = () => {
setVisible(false);
setImgSrc('');
};
const checkIfInLink = (target) => {
let ret = false;
let el = target.parentElement;
while (el) {
if (el.nodeName.toLowerCase() === 'a') {
ret = true;
break;
}
el = el.parentElement;
}
return ret;
};
const checkClickForImgView = (evt: MouseEvent<HTMLElement>) => {
const { target } = evt;
// @ts-ignore
if (target.nodeName.toLowerCase() !== 'img') {
return;
}
const img = target as HTMLImageElement;
if (!img.naturalWidth || !img.naturalHeight) {
img.classList.add('broken');
return;
}
const src = img.currentSrc || img.src;
if (src && checkIfInLink(img) === false) {
setImgSrc(src);
setVisible(true);
}
};
useLayoutEffect(() => {
root.render(
<Modal
show={visible}
fullscreen
centered
scrollable
contentClassName="bg-transparent"
onHide={onClose}>
<Modal.Body onClick={onClose} className="p-0 d-flex">
<img
className="cursor-zoom-out img-fluid m-auto"
src={imgSrc}
alt={imgSrc}
/>
</Modal.Body>
</Modal>,
);
});
useEffect(() => {
onClose();
}, [location]);
return {
onClose,
checkClickForImgView,
};
};
export default useImgViewer;

View File

@ -6,7 +6,7 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
// https://gist.github.com/chaance/2f3c14ec2351a175024f62fd6ba64aa6 // https://gist.github.com/chaance/2f3c14ec2351a175024f62fd6ba64aa6
// The link above is an example of implementing usePromt with useBlocer. // The link above is an example of implementing usePrompt with useBlocker.
interface PromptProps { interface PromptProps {
when: boolean; when: boolean;
beforeUnload?: boolean; beforeUnload?: boolean;

View File

@ -47,7 +47,11 @@ const useReportModal = (callback?: () => void) => {
setShow(true); setShow(true);
}); });
}; };
const asyncCallback = () => {
setTimeout(() => {
callback?.();
});
};
const handleRadio = (val) => { const handleRadio = (val) => {
setInvalidState(false); setInvalidState(false);
setContent({ setContent({
@ -93,8 +97,8 @@ const useReportModal = (callback?: () => void) => {
close_type: reportType.type, close_type: reportType.type,
close_msg: content.value, close_msg: content.value,
}).then(() => { }).then(() => {
callback?.();
onClose(); onClose();
asyncCallback();
}); });
return; return;
} }
@ -109,8 +113,8 @@ const useReportModal = (callback?: () => void) => {
msg: t('flag_success', { keyPrefix: 'toast' }), msg: t('flag_success', { keyPrefix: 'toast' }),
variant: 'warning', variant: 'warning',
}); });
callback?.();
onClose(); onClose();
asyncCallback();
}); });
} }
@ -121,8 +125,8 @@ const useReportModal = (callback?: () => void) => {
flagged_type: reportType.type, flagged_type: reportType.type,
id: params.id, id: params.id,
}).then(() => { }).then(() => {
callback?.();
onClose(); onClose();
asyncCallback();
}); });
} }
}; };

View File

@ -120,6 +120,14 @@ a {
cursor: pointer; cursor: pointer;
} }
.cursor-zoom-out {
cursor: zoom-out !important;
}
img:not(a img, img.broken) {
cursor: zoom-in;
}
.resize-none { .resize-none {
resize: none; resize: none;
} }

View File

@ -1,17 +1,30 @@
import { useEffect } from 'react';
import { Container, Button } from 'react-bootstrap'; import { Container, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const Index = () => { const Index = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_404' }); const { t } = useTranslation('translation', { keyPrefix: 'page_404' });
useEffect(() => {
// auto height of container
const pageWrap = document.querySelector('.page-wrap');
pageWrap.style.display = 'contents';
return () => {
pageWrap.style.display = 'block';
};
}, []);
return ( return (
<Container className="d-flex flex-column justify-content-center align-items-center page-wrap"> <Container
className="d-flex flex-column justify-content-center align-items-center"
style={{ flex: 1 }}>
<div <div
className="mb-4 text-secondary" className="mb-4 text-secondary"
style={{ fontSize: '120px', lineHeight: 1.2 }}> style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=x=) (=x=)
</div> </div>
<div className="text-center mb-4">{t('desc')}</div> <h4 className="text-center">{t('http_error')}</h4>
<div className="text-center mb-3 fs-5">{t('desc')}</div>
<div className="text-center"> <div className="text-center">
<Button as={Link} to="/" variant="link"> <Button as={Link} to="/" variant="link">
{t('back_home')} {t('back_home')}

View File

@ -1,9 +1,19 @@
import { useEffect } from 'react';
import { Container, Button } from 'react-bootstrap'; import { Container, Button } from 'react-bootstrap';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const Index = () => { const Index = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_50X' }); const { t } = useTranslation('translation', { keyPrefix: 'page_50X' });
useEffect(() => {
// auto height of container
const pageWrap = document.querySelector('.page-wrap');
pageWrap.style.display = 'contents';
return () => {
pageWrap.style.display = 'block';
};
}, []);
return ( return (
<Container className="d-flex flex-column justify-content-center align-items-center page-wrap"> <Container className="d-flex flex-column justify-content-center align-items-center page-wrap">
<div <div
@ -11,7 +21,9 @@ const Index = () => {
style={{ fontSize: '120px', lineHeight: 1.2 }}> style={{ fontSize: '120px', lineHeight: 1.2 }}>
(=T^T=) (=T^T=)
</div> </div>
<div className="text-center mb-3">{t('desc')}</div>
<h4 className="text-center">{t('http_error')}</h4>
<div className="text-center mb-3 fs-5">{t('desc')}</div>
<div className="text-center"> <div className="text-center">
<Button as={Link} to="/" variant="link"> <Button as={Link} to="/" variant="link">
{t('back_home')} {t('back_home')}

View File

@ -1,10 +1,10 @@
import { FC, memo } from 'react'; import { FC, memo, useEffect } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async'; import { HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr'; import { SWRConfig } from 'swr';
import { toastStore, loginToContinueStore } from '@/stores'; import { toastStore, loginToContinueStore, notFoundStore } from '@/stores';
import { import {
Header, Header,
Footer, Footer,
@ -14,13 +14,23 @@ import {
PageTags, PageTags,
} from '@/components'; } from '@/components';
import { LoginToContinueModal } from '@/components/Modal'; import { LoginToContinueModal } from '@/components/Modal';
import { useImgViewer } from '@/hooks';
import Component404 from '@/pages/404';
const Layout: FC = () => { const Layout: FC = () => {
const location = useLocation();
const { msg: toastMsg, variant, clear: toastClear } = toastStore(); const { msg: toastMsg, variant, clear: toastClear } = toastStore();
const closeToast = () => { const closeToast = () => {
toastClear(); toastClear();
}; };
const { visible: show404, hide: notFoundHide } = notFoundStore();
const imgViewer = useImgViewer();
const { show: showLoginToContinueModal } = loginToContinueStore(); const { show: showLoginToContinueModal } = loginToContinueStore();
useEffect(() => {
notFoundHide();
}, [location]);
return ( return (
<HelmetProvider> <HelmetProvider>
<PageTags /> <PageTags />
@ -30,8 +40,11 @@ const Layout: FC = () => {
revalidateOnFocus: false, revalidateOnFocus: false,
}}> }}>
<Header /> <Header />
<div className="position-relative page-wrap"> {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<Outlet /> <div
className="position-relative page-wrap"
onClick={imgViewer.checkClickForImgView}>
{show404 ? <Component404 /> : <Outlet />}
</div> </div>
<Toast msg={toastMsg} variant={variant} onClose={closeToast} /> <Toast msg={toastMsg} variant={variant} onClose={closeToast} />
<Footer /> <Footer />

View File

@ -10,6 +10,7 @@ import { isEqual } from 'lodash';
import { usePageTags, usePromptWithUnload } from '@/hooks'; import { usePageTags, usePromptWithUnload } from '@/hooks';
import { Editor, EditorRef, TagSelector } from '@/components'; import { Editor, EditorRef, TagSelector } from '@/components';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
import { DRAFT_QUESTION_STORAGE_KEY } from '@/common/constants';
import { import {
saveQuestion, saveQuestion,
questionDetail, questionDetail,
@ -19,7 +20,7 @@ import {
useQueryQuestionByTitle, useQueryQuestionByTitle,
getTagsBySlugName, getTagsBySlugName,
} from '@/services'; } from '@/services';
import { handleFormError } from '@/utils'; import { handleFormError, SaveDraft, storageExpires } from '@/utils';
import { pathFactory } from '@/router/pathFactory'; import { pathFactory } from '@/router/pathFactory';
import SearchQuestion from './components/SearchQuestion'; import SearchQuestion from './components/SearchQuestion';
@ -32,6 +33,8 @@ interface FormDataItem {
edit_summary: Type.FormValue<string>; edit_summary: Type.FormValue<string>;
} }
const saveDraft = new SaveDraft({ type: 'question' });
const Ask = () => { const Ask = () => {
const initFormData = { const initFormData = {
title: { title: {
@ -66,6 +69,7 @@ const Ask = () => {
const [checked, setCheckState] = useState(false); const [checked, setCheckState] = useState(false);
const [contentChanged, setContentChanged] = useState(false); const [contentChanged, setContentChanged] = useState(false);
const [focusType, setForceType] = useState(''); const [focusType, setForceType] = useState('');
const [hasDraft, setHasDraft] = useState(false);
const resetForm = () => { const resetForm = () => {
setFormData(initFormData); setFormData(initFormData);
setCheckState(false); setCheckState(false);
@ -98,6 +102,34 @@ const Ask = () => {
isEdit ? '' : formData.title.value, isEdit ? '' : formData.title.value,
); );
const removeDraft = () => {
saveDraft.save.cancel();
saveDraft.remove();
setHasDraft(false);
};
useEffect(() => {
if (!qid) {
initQueryTags();
const draft = storageExpires.get(DRAFT_QUESTION_STORAGE_KEY);
if (draft) {
formData.title.value = draft.title;
formData.content.value = draft.content;
formData.tags.value = draft.tags;
formData.answer.value = draft.answer;
setCheckState(Boolean(draft.answer));
setHasDraft(true);
setFormData({ ...formData });
} else {
resetForm();
}
}
return () => {
resetForm();
};
}, [qid]);
useEffect(() => { useEffect(() => {
const { title, tags, content, answer } = formData; const { title, tags, content, answer } = formData;
const { title: editTitle, tags: editTags, content: editContent } = immData; const { title: editTitle, tags: editTags, content: editContent } = immData;
@ -118,11 +150,21 @@ const Ask = () => {
} }
return; return;
} }
// write // write
if (title.value || tags.value.length > 0 || content.value || answer.value) { if (title.value || tags.value.length > 0 || content.value || answer.value) {
// save draft
saveDraft.save({
params: {
title: title.value,
tags: tags.value,
content: content.value,
answer: answer.value,
},
callback: () => setHasDraft(true),
});
setContentChanged(true); setContentChanged(true);
} else { } else {
removeDraft();
setContentChanged(false); setContentChanged(false);
} }
}, [formData]); }, [formData]);
@ -131,12 +173,6 @@ const Ask = () => {
when: contentChanged, when: contentChanged,
}); });
useEffect(() => {
if (!isEdit) {
resetForm();
initQueryTags();
}
}, [isEdit]);
const { data: revisions = [] } = useQueryRevisions(qid); const { data: revisions = [] } = useQueryRevisions(qid);
useEffect(() => { useEffect(() => {
@ -191,6 +227,14 @@ const Ask = () => {
}, },
}); });
const deleteDraft = () => {
const res = window.confirm(t('discard_confirm', { keyPrefix: 'draft' }));
if (res) {
removeDraft();
resetForm();
}
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
setContentChanged(false); setContentChanged(false);
event.preventDefault(); event.preventDefault();
@ -248,6 +292,7 @@ const Ask = () => {
navigate(pathFactory.questionLanding(id)); navigate(pathFactory.questionLanding(id));
} }
} }
removeDraft();
} }
}; };
const backPage = () => { const backPage = () => {
@ -376,10 +421,17 @@ const Ask = () => {
<Button type="submit" className="me-2"> <Button type="submit" className="me-2">
{isEdit ? t('btn_save_edits') : t('btn_post_question')} {isEdit ? t('btn_save_edits') : t('btn_post_question')}
</Button> </Button>
{isEdit && (
<Button variant="link" onClick={backPage}>
{t('cancel', { keyPrefix: 'btns' })}
</Button>
)}
<Button variant="link" onClick={backPage}> {hasDraft && (
{t('cancel', { keyPrefix: 'btns' })} <Button variant="link" onClick={deleteDraft}>
</Button> {t('discard_draft', { keyPrefix: 'btns' })}
</Button>
)}
</div> </div>
)} )}
{!isEdit && ( {!isEdit && (
@ -411,7 +463,6 @@ const Ask = () => {
}} }}
/> />
<Form.Control <Form.Control
value={formData.answer.value}
type="text" type="text"
isInvalid={formData.answer.isInvalid} isInvalid={formData.answer.isInvalid}
hidden hidden
@ -424,9 +475,14 @@ const Ask = () => {
</> </>
)} )}
{checked && ( {checked && (
<Button type="submit" className="mt-3"> <div className="mt-3">
{t('post_question&answer')} <Button type="submit">{t('post_question&answer')}</Button>
</Button> {hasDraft && (
<Button variant="link" className="ms-2" onClick={deleteDraft}>
{t('discard_draft', { keyPrefix: 'btns' })}
</Button>
)}
</div>
)} )}
</Form> </Form>
</Col> </Col>

View File

@ -10,38 +10,38 @@ interface Props {
const Index: FC<Props> = ({ data }) => { const Index: FC<Props> = ({ data }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Alert className="mb-4" variant="info"> <Alert className="mb-4" variant={data.level}>
<div> {data.level === 'info' ? (
{data.operation_msg.indexOf('http') > -1 ? ( <div>
<p> {data.msg.indexOf('http') > -1 ? (
{data.operation_description}{' '} <p>
<a href={data.operation_msg} style={{ color: '#055160' }}> {data.description}{' '}
<strong>{t('question_detail.show_exist')}</strong> <a href={data.msg} style={{ color: '#055160' }}>
</a> <strong>{t('question_detail.show_exist')}</strong>
</p> </a>
) : ( </p>
<p> ) : (
{data.operation_msg <p>{data.msg ? data.msg : data.description}</p>
? data.operation_msg )}
: data.operation_description} <div className="fs-14">
</p> {t('question_detail.closed_in')}{' '}
)} <time
<div className="fs-14"> dateTime={dayjs.unix(data.time).tz().toISOString()}
{t('question_detail.closed_in')}{' '} title={dayjs
<time .unix(data.time)
dateTime={dayjs.unix(data.operation_time).tz().toISOString()} .tz()
title={dayjs .format(t('dates.long_date_with_time'))}>
.unix(data.operation_time) {dayjs
.tz() .unix(data.time)
.format(t('dates.long_date_with_time'))}> .tz()
{dayjs .format(t('dates.long_date_with_year'))}
.unix(data.operation_time) </time>
.tz() .
.format(t('dates.long_date_with_year'))} </div>
</time>
.
</div> </div>
</div> ) : (
data.msg
)}
</Alert> </Alert>
); );
}; };

View File

@ -1,5 +1,5 @@
import { memo, FC, useEffect, useRef } from 'react'; import { memo, FC, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap'; import { Button, Alert } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
@ -72,6 +72,11 @@ const Index: FC<Props> = ({
} }
return ( return (
<div id={data.id} ref={answerRef} className="answer-item py-4"> <div id={data.id} ref={answerRef} className="answer-item py-4">
{data.status === 10 && (
<Alert variant="danger" className="mb-4">
{t('post_deleted', { keyPrefix: 'messages' })}
</Alert>
)}
<article <article
dangerouslySetInnerHTML={{ __html: data?.html }} dangerouslySetInnerHTML={{ __html: data?.html }}
className="fmt text-break text-wrap" className="fmt text-break text-wrap"

View File

@ -1,4 +1,4 @@
import { memo, useState, FC } from 'react'; import { memo, useState, FC, useEffect } from 'react';
import { Form, Button } from 'react-bootstrap'; import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,7 +9,8 @@ import { usePromptWithUnload } from '@/hooks';
import { Editor, Modal, TextArea } from '@/components'; import { Editor, Modal, TextArea } from '@/components';
import { FormDataType } from '@/common/interface'; import { FormDataType } from '@/common/interface';
import { postAnswer } from '@/services'; import { postAnswer } from '@/services';
import { guard, handleFormError } from '@/utils'; import { guard, handleFormError, SaveDraft, storageExpires } from '@/utils';
import { DRAFT_ANSWER_STORAGE_KEY } from '@/common/constants';
interface Props { interface Props {
visible?: boolean; visible?: boolean;
@ -21,6 +22,8 @@ interface Props {
callback?: (obj) => void; callback?: (obj) => void;
} }
const saveDraft = new SaveDraft({ type: 'answer' });
const Index: FC<Props> = ({ visible = false, data, callback }) => { const Index: FC<Props> = ({ visible = false, data, callback }) => {
const { t } = useTranslation('translation', { const { t } = useTranslation('translation', {
keyPrefix: 'question_detail.write_answer', keyPrefix: 'question_detail.write_answer',
@ -35,11 +38,51 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
const [showEditor, setShowEditor] = useState<boolean>(visible); const [showEditor, setShowEditor] = useState<boolean>(visible);
const [focusType, setFocusType] = useState(''); const [focusType, setFocusType] = useState('');
const [editorFocusState, setEditorFocusState] = useState(false); const [editorFocusState, setEditorFocusState] = useState(false);
const [hasDraft, setHasDraft] = useState(false);
usePromptWithUnload({ usePromptWithUnload({
when: Boolean(formData.content.value), when: Boolean(formData.content.value),
}); });
const removeDraft = () => {
// immediately remove debounced save
saveDraft.save.cancel();
saveDraft.remove();
setHasDraft(false);
};
useEffect(() => {
const draft = storageExpires.get(DRAFT_ANSWER_STORAGE_KEY);
if (draft?.questionId === data.qid && draft?.content) {
setFormData({
content: {
value: draft.content,
isInvalid: false,
errorMsg: '',
},
});
setShowEditor(true);
setHasDraft(true);
}
}, []);
useEffect(() => {
const draft = storageExpires.get(DRAFT_ANSWER_STORAGE_KEY);
const { content } = formData;
if (content.value) {
// save Draft
saveDraft.save({
questionId: data?.qid,
content: content.value,
});
setHasDraft(true);
} else if (draft?.questionId === data.qid && !content.value) {
removeDraft();
}
}, [formData.content.value]);
const checkValidated = (): boolean => { const checkValidated = (): boolean => {
let bol = true; let bol = true;
const { content } = formData; const { content } = formData;
@ -65,6 +108,24 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
return bol; return bol;
}; };
const resetForm = () => {
setFormData({
content: {
value: '',
isInvalid: false,
errorMsg: '',
},
});
};
const deleteDraft = () => {
const res = window.confirm(t('discard_confirm', { keyPrefix: 'draft' }));
if (res) {
removeDraft();
resetForm();
}
};
const handleSubmit = () => { const handleSubmit = () => {
if (!guard.tryNormalLogged(true)) { if (!guard.tryNormalLogged(true)) {
return; return;
@ -86,6 +147,7 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
errorMsg: '', errorMsg: '',
}, },
}); });
removeDraft();
callback?.(res.info); callback?.(res.info);
}) })
.catch((ex) => { .catch((ex) => {
@ -128,7 +190,6 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
setShowEditor(true); setShowEditor(true);
setEditorFocusState(true); setEditorFocusState(true);
}; };
return ( return (
<Form noValidate className="mt-4"> <Form noValidate className="mt-4">
{(!data.answered || showEditor) && ( {(!data.answered || showEditor) && (
@ -187,6 +248,11 @@ const Index: FC<Props> = ({ visible = false, data, callback }) => {
) : ( ) : (
<Button onClick={clickBtn}>{t('btn_name')}</Button> <Button onClick={clickBtn}>{t('btn_name')}</Button>
)} )}
{hasDraft && (
<Button variant="link" className="ms-2" onClick={deleteDraft}>
{t('discard_draft', { keyPrefix: 'btns' })}
</Button>
)}
</Form> </Form>
); );
}; };

View File

@ -56,6 +56,7 @@ const Index = () => {
const { setUsers } = usePageUsers(); const { setUsers } = usePageUsers();
const userInfo = loggedUserInfoStore((state) => state.user); const userInfo = loggedUserInfoStore((state) => state.user);
const isAuthor = userInfo?.username === question?.user_info?.username; const isAuthor = userInfo?.username === question?.user_info?.username;
const isAdmin = userInfo?.is_admin;
const isLogged = Boolean(userInfo?.access_token); const isLogged = Boolean(userInfo?.access_token);
const { state: locationState } = useLocation(); const { state: locationState } = useLocation();
@ -76,7 +77,22 @@ const Index = () => {
page_size: 999, page_size: 999,
}); });
if (res) { if (res) {
setAnswers(res); res.list = res.list?.filter((v) => {
// delete answers pnly show to author and admin and has searchparams aid
if (v.status === 10) {
if (
(v?.user_info.username === userInfo?.username || isAdmin) &&
aid === v.id
) {
return v;
}
return null;
}
return v;
});
setAnswers({ ...res, count: res.list.length });
if (page > 0 || order) { if (page > 0 || order) {
// scroll into view; // scroll into view;
const element = document.getElementById('answerHeader'); const element = document.getElementById('answerHeader');
@ -183,9 +199,7 @@ const Index = () => {
<Container className="pt-4 mt-2 mb-5 questionDetailPage"> <Container className="pt-4 mt-2 mb-5 questionDetailPage">
<Row className="justify-content-center"> <Row className="justify-content-center">
<Col xxl={7} lg={8} sm={12} className="mb-5 mb-md-0"> <Col xxl={7} lg={8} sm={12} className="mb-5 mb-md-0">
{question?.operation?.operation_type && ( {question?.operation?.level && <Alert data={question.operation} />}
<Alert data={question.operation} />
)}
{isLoading ? ( {isLoading ? (
<ContentLoader /> <ContentLoader />
) : ( ) : (

View File

@ -18,7 +18,7 @@ const Index: FC<Props> = ({ visible, introduction, data }) => {
<h5 className="mb-3">{t('about_me')}</h5> <h5 className="mb-3">{t('about_me')}</h5>
{introduction ? ( {introduction ? (
<div <div
className="mb-4 text-break" className="mb-4 text-break fmt"
dangerouslySetInnerHTML={{ __html: introduction }} dangerouslySetInnerHTML={{ __html: introduction }}
/> />
) : ( ) : (

View File

@ -11,6 +11,7 @@ import {
usePersonalTop, usePersonalTop,
usePersonalListByTabName, usePersonalListByTabName,
} from '@/services'; } from '@/services';
import type { UserInfoRes } from '@/common/interface';
import { import {
UserInfo, UserInfo,
@ -47,8 +48,8 @@ const Personal: FC = () => {
tabName, tabName,
); );
let pageTitle = ''; let pageTitle = '';
if (userInfo) { if (userInfo?.username) {
pageTitle = `${userInfo.info.display_name} (${userInfo.info.username})`; pageTitle = `${userInfo?.display_name} (${userInfo?.username})`;
} }
const { count = 0, list = [] } = listData?.[tabName] || {}; const { count = 0, list = [] } = listData?.[tabName] || {};
usePageTags({ usePageTags({
@ -57,11 +58,11 @@ const Personal: FC = () => {
return ( return (
<Container className="pt-4 mt-2 mb-5"> <Container className="pt-4 mt-2 mb-5">
<Row className="justify-content-center"> <Row className="justify-content-center">
{userInfo?.info?.status !== 'normal' && userInfo?.info?.status_msg && ( {userInfo?.status !== 'normal' && userInfo?.status_msg && (
<Alert data={userInfo?.info.status_msg} /> <Alert data={userInfo?.status_msg} />
)} )}
<Col xxl={7} lg={8} sm={12}> <Col xxl={7} lg={8} sm={12}>
<UserInfo data={userInfo?.info} /> <UserInfo data={userInfo as UserInfoRes} />
</Col> </Col>
<Col <Col
xxl={3} xxl={3}
@ -88,11 +89,11 @@ const Personal: FC = () => {
<Col xxl={7} lg={8} sm={12}> <Col xxl={7} lg={8} sm={12}>
<Overview <Overview
visible={tabName === 'overview'} visible={tabName === 'overview'}
introduction={userInfo?.info?.bio_html} introduction={userInfo?.bio_html || ''}
data={topData} data={topData}
/> />
<ListHead <ListHead
count={tabName === 'reputation' ? userInfo?.info?.rank : count} count={tabName === 'reputation' ? Number(userInfo?.rank) : count}
sort={order} sort={order}
visible={tabName !== 'overview'} visible={tabName !== 'overview'}
tabName={tabName} tabName={tabName}
@ -120,17 +121,14 @@ const Personal: FC = () => {
</Col> </Col>
<Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0"> <Col xxl={3} lg={4} sm={12} className="mt-5 mt-lg-0">
<h5 className="mb-3">{t('stats')}</h5> <h5 className="mb-3">{t('stats')}</h5>
{userInfo?.info && ( {userInfo?.created_at && (
<> <>
<div className="text-secondary"> <div className="text-secondary">
<FormatTime <FormatTime time={userInfo.created_at} preFix={t('joined')} />
time={userInfo.info.created_at}
preFix={t('joined')}
/>
</div> </div>
<div className="text-secondary"> <div className="text-secondary">
<FormatTime <FormatTime
time={userInfo.info.last_login_date} time={userInfo.last_login_date}
preFix={t('last_login')} preFix={t('last_login')}
/> />
</div> </div>

View File

@ -0,0 +1,8 @@
import Error50X from '@/pages/50X';
// import Page404 from '@/pages/404';
const Index = () => {
return <Error50X />;
};
export default Index;

View File

@ -2,9 +2,10 @@ import { Suspense, lazy } from 'react';
import { RouteObject } from 'react-router-dom'; import { RouteObject } from 'react-router-dom';
import Layout from '@/pages/Layout'; import Layout from '@/pages/Layout';
import ErrorBoundary from '@/pages/50X';
import baseRoutes, { RouteNode } from '@/router/routes'; import baseRoutes, { RouteNode } from './routes';
import RouteGuard from '@/router/RouteGuard'; import RouteGuard from './RouteGuard';
import RouteErrorBoundary from './RouteErrorBoundary';
const routes: RouteNode[] = []; const routes: RouteNode[] = [];
@ -18,7 +19,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteNode[]) => {
) : ( ) : (
<Layout /> <Layout />
); );
rn.errorElement = <ErrorBoundary />; rn.errorElement = <RouteErrorBoundary />;
} else { } else {
/** /**
* cannot use a fully dynamic import statement * cannot use a fully dynamic import statement
@ -37,6 +38,7 @@ const routeWrapper = (routeNodes: RouteNode[], root: RouteNode[]) => {
)} )}
</Suspense> </Suspense>
); );
rn.errorElement = <RouteErrorBoundary />;
} }
root.push(rn); root.push(rn);
const children = Array.isArray(rn.children) ? rn.children : null; const children = Array.isArray(rn.children) ? rn.children : null;

View File

@ -8,7 +8,10 @@ export const usePersonalInfoByName = (username: string) => {
const apiUrl = '/answer/api/v1/personal/user/info'; const apiUrl = '/answer/api/v1/personal/user/info';
const { data, error, mutate } = useSWR<Type.UserInfoRes, Error>( const { data, error, mutate } = useSWR<Type.UserInfoRes, Error>(
username ? `${apiUrl}?username=${username}` : null, username ? `${apiUrl}?username=${username}` : null,
request.instance.get, (url) =>
request.get(url, {
allow404: true,
}),
); );
return { return {
data, data,

View File

@ -47,7 +47,9 @@ export const useTagInfo = ({ id = '', name = '' }) => {
name = encodeURIComponent(name); name = encodeURIComponent(name);
apiUrl = `/answer/api/v1/tag?name=${name}`; apiUrl = `/answer/api/v1/tag?name=${name}`;
} }
const { data, error } = useSWR<Type.TagInfo>(apiUrl, request.instance.get); const { data, error } = useSWR<Type.TagInfo>(apiUrl, (url) =>
request.get(url, { allow404: true }),
);
return { return {
data, data,
isLoading: !data && !error, isLoading: !data && !error,

View File

@ -171,6 +171,7 @@ export const saveQuestion = (params: Type.QuestionParams) => {
export const questionDetail = (id: string) => { export const questionDetail = (id: string) => {
return request.get<Type.QuestionDetailRes>( return request.get<Type.QuestionDetailRes>(
`/answer/api/v1/question/info?id=${id}`, `/answer/api/v1/question/info?id=${id}`,
{ allow404: true },
); );
}; };

View File

@ -10,6 +10,7 @@ import pageTagStore from './pageTags';
import customizeStore from './customize'; import customizeStore from './customize';
import themeSettingStore from './themeSetting'; import themeSettingStore from './themeSetting';
import loginToContinueStore from './loginToContinue'; import loginToContinueStore from './loginToContinue';
import notFoundStore from './notFound';
export { export {
toastStore, toastStore,
@ -23,4 +24,5 @@ export {
themeSettingStore, themeSettingStore,
seoSettingStore, seoSettingStore,
loginToContinueStore, loginToContinueStore,
notFoundStore,
}; };

23
ui/src/stores/notFound.ts Normal file
View File

@ -0,0 +1,23 @@
import create from 'zustand';
interface NotFoundType {
visible: boolean;
show: () => void;
hide: () => void;
}
const notFound = create<NotFoundType>((set) => ({
visible: false,
show: () => {
set(() => {
return { visible: true };
});
},
hide: () => {
set(() => {
return { visible: false };
});
},
}));
export default notFound;

View File

@ -237,7 +237,26 @@ function htmlToReact(html: string) {
const cleanedHtml = DOMPurify.sanitize(html, { const cleanedHtml = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true }, USE_PROFILES: { html: true },
}); });
return parse(cleanedHtml);
const ele = document.createElement('div');
ele.innerHTML = cleanedHtml;
ele.querySelectorAll('table').forEach((table) => {
if (
(!table || (table.parentNode as HTMLDivElement))?.classList.contains(
'table-responsive',
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
return parse(ele.innerHTML);
} }
export { export {

View File

@ -63,7 +63,7 @@ export const deriveLoginState = (): TLoginState => {
return ls; return ls;
}; };
const isIgnoredPath = (ignoredPath: string | string[]) => { export const isIgnoredPath = (ignoredPath: string | string[]) => {
if (!Array.isArray(ignoredPath)) { if (!Array.isArray(ignoredPath)) {
ignoredPath = [ignoredPath]; ignoredPath = [ignoredPath];
} }

View File

@ -1,6 +1,8 @@
export { default as request } from './request'; export { default as request } from './request';
export { default as Storage } from './storage'; export { default as Storage } from './storage';
export { floppyNavigation } from './floppyNavigation'; export { floppyNavigation } from './floppyNavigation';
export { default as storageExpires } from './storageWithExpires';
export { default as SaveDraft } from './saveDraft';
export * as guard from './guard'; export * as guard from './guard';
export * as localize from './localize'; export * as localize from './localize';

View File

@ -2,19 +2,24 @@ import axios, { AxiosResponse } from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'; import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import { Modal } from '@/components'; import { Modal } from '@/components';
import { loggedUserInfoStore, toastStore } from '@/stores'; import { loggedUserInfoStore, toastStore, notFoundStore } from '@/stores';
import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants'; import { LOGGED_TOKEN_STORAGE_KEY, IGNORE_PATH_LIST } from '@/common/constants';
import { RouteAlias } from '@/router/alias'; import { RouteAlias } from '@/router/alias';
import { getCurrentLang } from '@/utils/localize'; import { getCurrentLang } from '@/utils/localize';
import Storage from './storage'; import Storage from './storage';
import { floppyNavigation } from './floppyNavigation'; import { floppyNavigation } from './floppyNavigation';
import { isIgnoredPath } from './guard';
const baseConfig = { const baseConfig = {
timeout: 10000, timeout: 10000,
withCredentials: true, withCredentials: true,
}; };
interface APIconfig extends AxiosRequestConfig {
allow404: boolean;
}
class Request { class Request {
instance: AxiosInstance; instance: AxiosInstance;
@ -49,6 +54,9 @@ class Request {
(error) => { (error) => {
const { status, data: respData } = error.response || {}; const { status, data: respData } = error.response || {};
const { data = {}, msg = '', reason = '' } = respData || {}; const { data = {}, msg = '', reason = '' } = respData || {};
console.log('response error:', error);
if (status === 400) { if (status === 400) {
// show error message // show error message
if (data instanceof Object && data.err_type) { if (data instanceof Object && data.err_type) {
@ -99,12 +107,14 @@ class Request {
// 401: Re-login required // 401: Re-login required
if (status === 401) { if (status === 401) {
// clear userinfo // clear userinfo
notFoundStore.getState().hide();
loggedUserInfoStore.getState().clear(); loggedUserInfoStore.getState().clear();
floppyNavigation.navigateToLogin(); floppyNavigation.navigateToLogin();
return Promise.reject(false); return Promise.reject(false);
} }
if (status === 403) { if (status === 403) {
// Permission interception // Permission interception
notFoundStore.getState().hide();
if (data?.type === 'url_expired') { if (data?.type === 'url_expired') {
// url expired // url expired
floppyNavigation.navigate(RouteAlias.activationFailed, () => { floppyNavigation.navigate(RouteAlias.activationFailed, () => {
@ -135,6 +145,14 @@ class Request {
} }
return Promise.reject(false); return Promise.reject(false);
} }
if (status === 404 && error.config?.allow404) {
if (isIgnoredPath(IGNORE_PATH_LIST)) {
return Promise.reject(false);
}
notFoundStore.getState().show();
return Promise.reject(false);
}
if (status >= 500) { if (status >= 500) {
console.error( console.error(
`Request failed with status code ${status}, ${msg || ''}`, `Request failed with status code ${status}, ${msg || ''}`,
@ -149,7 +167,7 @@ class Request {
return this.instance.request(config); return this.instance.request(config);
} }
public get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> { public get<T = any>(url: string, config?: APIconfig): Promise<T> {
return this.instance.get(url, config); return this.instance.get(url, config);
} }

87
ui/src/utils/saveDraft.ts Normal file
View File

@ -0,0 +1,87 @@
import { debounce } from 'lodash';
import {
DRAFT_QUESTION_STORAGE_KEY,
DRAFT_ANSWER_STORAGE_KEY,
} from '@/common/constants';
import { storageExpires as storage } from '@/utils';
export type QuestionDraft = {
params: {
title: string;
content: string;
tags: any[];
answer: string;
};
callback?: () => void;
};
export type AnswerDraft = {
questionId: string;
content: string;
callback?: () => void;
};
type DraftType = {
type: 'question' | 'answer';
};
export type DraftParams = QuestionDraft | AnswerDraft;
class SaveDraft {
type: DraftType['type'];
status: 'save' | 'remove';
constructor({ type = 'question' }: DraftType) {
this.type = type;
this.status = 'save';
}
save = debounce((data: DraftParams) => {
// TODO
if (this.status === 'remove') {
return;
}
if (this.type === 'question') {
const { params, callback } = data as QuestionDraft;
this.storeDraft(params, callback);
}
if (this.type === 'answer') {
const { content, questionId, callback } = data as AnswerDraft;
if (!questionId || !content) {
return;
}
this.storeDraft({ content, questionId }, callback);
}
}, 3000);
remove() {
this.status = 'remove';
const that = this;
if (this.type === 'question') {
storage.remove(DRAFT_QUESTION_STORAGE_KEY, () => {
that.status = 'save';
});
}
if (this.type === 'answer') {
storage.remove(DRAFT_ANSWER_STORAGE_KEY, () => {
that.status = 'save';
});
}
}
private storeDraft = (params: any, callback) => {
const key =
this.type === 'question'
? DRAFT_QUESTION_STORAGE_KEY
: DRAFT_ANSWER_STORAGE_KEY;
storage.set(key, params);
callback?.();
};
}
export default SaveDraft;

View File

@ -0,0 +1,50 @@
import { DRAFT_TIMESIGH_STORAGE_KEY as timeSign } from '@/common/constants';
const store = {
storage: localStorage || window.localStorage,
set(key: string, value, time?: number): void {
const t = time || Date.now() + 1000 * 60 * 60 * 24 * 7; // default 7 days
try {
this.storage.setItem(key, `${t}${timeSign}${JSON.stringify(value)}`);
} catch {
// ignore
console.error('set storage error: the key is', key);
}
},
get(key: string): any {
const timeSignLen = timeSign.length;
let index = 0;
let time = 0;
let res;
try {
res = this.storage.getItem(key);
} catch {
console.error('get storage error: the key is', key);
}
if (res) {
index = res.indexOf(timeSign);
time = +res.slice(0, index);
if (time > new Date().getTime()) {
res = res.slice(index + timeSignLen);
try {
res = JSON.parse(res);
} catch {
// ignore
}
} else {
// timeout remove storage
res = null;
this.storage.removeItem(key);
}
return res;
}
return res;
},
remove(key: string, callback?: () => void): void {
this.storage.removeItem(key);
callback?.();
},
};
export default store;

View File

@ -3,28 +3,28 @@
<div class="justify-content-center row"> <div class="justify-content-center row">
<div class="col-xxl-7 col-lg-8 col-sm-12"> <div class="col-xxl-7 col-lg-8 col-sm-12">
<div class="d-flex flex-column flex-md-row mb-4"> <div class="d-flex flex-column flex-md-row mb-4">
<a href="/users/{{.userinfo.Info.Username}}"><img <a href="/users/{{.userinfo.Username}}"><img
src="{{.userinfo.Info.Avatar}}" src="{{.userinfo.Avatar}}"
width="160px" height="160px" class="rounded" alt="" /></a> width="160px" height="160px" class="rounded" alt="" /></a>
<div class="ms-0 ms-md-4 mt-4 mt-md-0"> <div class="ms-0 ms-md-4 mt-4 mt-md-0">
<div class="d-flex align-items-center mb-2"> <div class="d-flex align-items-center mb-2">
<a class="link-dark h3 mb-0" href="/users/{{.userinfo.Info.Username}}">{{.userinfo.Info.DisplayName}}</a> <a class="link-dark h3 mb-0" href="/users/{{.userinfo.Username}}">{{.userinfo.DisplayName}}</a>
</div> </div>
<div class="text-secondary mb-4">@{{.userinfo.Info.Username}}</div> <div class="text-secondary mb-4">@{{.userinfo.Username}}</div>
<div class="d-flex flex-wrap mb-3"> <div class="d-flex flex-wrap mb-3">
<div class="me-3"> <div class="me-3">
<strong class="fs-5">{{.userinfo.Info.Rank}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_reputation"}}</span> <strong class="fs-5">{{.userinfo.Rank}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_reputation"}}</span>
</div> </div>
<div class="me-3"> <div class="me-3">
<strong class="fs-5">{{.userinfo.Info.AnswerCount}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_answers"}}</span> <strong class="fs-5">{{.userinfo.AnswerCount}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_answers"}}</span>
</div> </div>
<div> <div>
<strong class="fs-5">{{.userinfo.Info.QuestionCount}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_questions"}}</span> <strong class="fs-5">{{.userinfo.QuestionCount}}</strong><span class="text-secondary"> {{translator $.language "ui.personal.x_questions"}}</span>
</div> </div>
</div> </div>
{{if .userinfo.Info.Website }} {{if .userinfo.Website }}
<div class="d-flex align-items-center"><i class="br bi-house-door-fill me-2"></i><a class="link-secondary" href="{{.userinfo.Info.Website}}">{{.userinfo.Info.Website}}</a></div> <div class="d-flex align-items-center"><i class="br bi-house-door-fill me-2"></i><a class="link-secondary" href="{{.userinfo.Website}}">{{.userinfo.Website}}</a></div>
{{else}} {{else}}
{{end}} {{end}}
<div class="d-flex text-secondary"></div> <div class="d-flex text-secondary"></div>