Merge remote-tracking branch 'origin/dev' into feat/1.3.0/review

This commit is contained in:
LinkinStars
2024-03-14 16:17:00 +08:00
25 changed files with 204 additions and 19 deletions
+4
View File
@@ -1081,6 +1081,7 @@ ui:
title: Answers
score: Score
newest: Newest
oldest: Oldest
btn_accept: Accept
btn_accepted: Accepted
write_answer:
@@ -1596,6 +1597,9 @@ ui:
msg: Contact email cannot be empty.
validate: Contact email is not valid.
text: Email address of key contact responsible for this site.
check_update:
label: Software updates
text: Automatically check for updates
interface:
page_title: Interface
language:
+3
View File
@@ -59,3 +59,6 @@ language_options:
- label: "Slovak"
value: "sk_SK"
progress: 62
- label: "فارسی"
value: "fa_IR"
progress: 85
+4
View File
@@ -1050,6 +1050,7 @@ ui:
title: 个回答
score: 评分
newest: 最新
oldest: 最旧
btn_accept: 采纳
btn_accepted: 已被采纳
write_answer:
@@ -1549,6 +1550,9 @@ ui:
msg: 联系人邮箱不能为空。
validate: 联系人邮箱无效。
text: 本网站的主要联系邮箱地址。
check_update:
label: 软件更新
text: 自动检查软件更新
interface:
page_title: 界面
language:
+1
View File
@@ -25,6 +25,7 @@ const (
AnswerSearchOrderByDefault = "default"
AnswerSearchOrderByTime = "updated"
AnswerSearchOrderByVote = "vote"
AnswerSearchOrderByTimeAsc = "created"
AnswerStatusAvailable = 1
AnswerStatusDeleted = 10
+2
View File
@@ -336,6 +336,8 @@ func (ar *answerRepo) SearchList(ctx context.Context, search *entity.AnswerSearc
switch search.Order {
case entity.AnswerSearchOrderByTime:
session = session.OrderBy("created_at desc")
case entity.AnswerSearchOrderByTimeAsc:
session = session.OrderBy("created_at asc")
case entity.AnswerSearchOrderByVote:
session = session.OrderBy("vote_count desc")
default:
+25 -3
View File
@@ -41,12 +41,34 @@ type SearchDTO struct {
func (s *SearchDTO) Check() (errField []*validator.FormErrorField, err error) {
// Replace special characters.
// Special characters will cause the search abnormal, such as search for "#" will get nearly all the content that Markdown format.
s.Query = regexp.MustCompile(`[+#.<>\-_()*]`).ReplaceAllString(s.Query, " ")
s.Query = regexp.MustCompile(`\s+`).ReplaceAllString(s.Query, " ")
s.Query = strings.TrimSpace(s.Query)
replacedContent, patterns := ReplaceSearchContent(s.Query)
s.Query = strings.Join(append(patterns, replacedContent), " ")
return nil, nil
}
func ReplaceSearchContent(content string) (string, []string) {
// Define the regular expressions for key:value pairs and [tag]
keyValueRegex := regexp.MustCompile(`\w+:\S+`)
tagRegex := regexp.MustCompile(`\[\w+\]`)
// Define the pattern for characters to replace
replaceCharsPattern := regexp.MustCompile(`[+#.<>\-_()*]`)
// Extract key:value pairs
keyValues := keyValueRegex.FindAllString(content, -1)
// Extract [tag]
tags := tagRegex.FindAllString(content, -1)
// Replace key:value pairs and [tag] with empty string
contentWithoutPatterns := keyValueRegex.ReplaceAllString(content, "")
contentWithoutPatterns = tagRegex.ReplaceAllString(contentWithoutPatterns, "")
// Replace characters with pattern [+#.<>_()*] with space
replacedContent := replaceCharsPattern.ReplaceAllString(contentWithoutPatterns, " ")
return strings.TrimSpace(replacedContent), append(keyValues, tags...)
}
type SearchCondition struct {
// search target type: all/question/answer
TargetType string
+22
View File
@@ -0,0 +1,22 @@
package schema
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestReplaceSearchContent(t *testing.T) {
content := "user:aaa [tag] ssssfdfdf-as#fsadf"
replacedContent, patterns := ReplaceSearchContent(content)
ret := strings.Join(append(patterns, replacedContent), " ")
assert.Equal(t, "user:aaa [tag] ssssfdfdf as fsadf", ret)
content = "user:aaa-sss [tag1] ssssfdfdf-as#fsadf [tag2] score:3"
replacedContent, patterns = ReplaceSearchContent(content)
ret = strings.Join(append(patterns, replacedContent), " ")
assert.Equal(t, "user:aaa-sss score:3 [tag1] [tag2] ssssfdfdf as fsadf", ret)
}
+1
View File
@@ -40,6 +40,7 @@ type SiteGeneralReq struct {
Description string `validate:"omitempty,sanitizer,gt=3,lte=2000" form:"description" json:"description"`
SiteUrl string `validate:"required,sanitizer,gt=1,lte=512,url" form:"site_url" json:"site_url"`
ContactEmail string `validate:"required,sanitizer,gt=1,lte=512,email" form:"contact_email" json:"contact_email"`
CheckUpdate bool `validate:"omitempty,sanitizer" form:"check_update" json:"check_update"`
}
func (r *SiteGeneralReq) FormatSiteUrl() {
+11 -1
View File
@@ -1259,7 +1259,17 @@ func (qs *QuestionService) SimilarQuestion(ctx context.Context, questionID strin
search.Tag = tagNames[0]
}
search.LoginUserID = loginUserID
return qs.GetQuestionPage(ctx, search)
similarQuestions, _, err := qs.GetQuestionPage(ctx, search)
if err != nil {
return nil, 0, err
}
var result []*schema.QuestionPageResp
for _, v := range similarQuestions {
if uid.DeShortID(v.ID) != questionID {
result = append(result, v)
}
}
return result, int64(len(result)), nil
}
// GetQuestionPage query questions page
+6
View File
@@ -645,6 +645,12 @@ func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string
if err != nil {
return nil, err
}
// if email status is to be verified, active user as well
if userInfo.MailStatus == entity.EmailStatusToBeVerified {
if err = us.userActivity.UserActive(ctx, userInfo.ID); err != nil {
log.Error(err)
}
}
roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID)
if err != nil {
@@ -23,11 +23,12 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/apache/incubator-answer/pkg/converter"
"io"
"net/http"
"net/url"
"time"
"github.com/apache/incubator-answer/pkg/converter"
"xorm.io/xorm/schemas"
"github.com/apache/incubator-answer/internal/base/constant"
@@ -101,7 +102,14 @@ func (ds *dashboardService) Statistical(ctx context.Context) (*schema.DashboardI
dashboardInfo.ReportCount = ds.reportCount(ctx)
dashboardInfo.VoteCount = ds.voteCount(ctx)
dashboardInfo.OccupyingStorageSpace = ds.calculateStorage()
dashboardInfo.VersionInfo.RemoteVersion = ds.remoteVersion(ctx)
general, err := ds.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get general site info failed: %s", err)
return dashboardInfo, nil
}
if general.CheckUpdate {
dashboardInfo.VersionInfo.RemoteVersion = ds.remoteVersion(ctx)
}
dashboardInfo.DatabaseVersion = ds.getDatabaseInfo()
dashboardInfo.DatabaseSize = ds.GetDatabaseSize()
}
@@ -92,7 +92,8 @@ func GetQuestionPermission(ctx context.Context, userID string, creatorUserID str
Type: "confirm",
})
}
if canDelete || userID == creatorUserID {
if (canDelete || userID == creatorUserID) && status != entity.QuestionStatusDeleted {
actions = append(actions, &schema.PermissionMemberAction{
Action: "delete",
Name: translator.Tr(lang, deleteActionName),
@@ -66,7 +66,7 @@ func NewSiteInfoCommonService(siteInfoRepo SiteInfoRepo) SiteInfoCommonService {
// GetSiteGeneral get site info general
func (s *siteInfoCommonService) GetSiteGeneral(ctx context.Context) (resp *schema.SiteGeneralResp, err error) {
resp = &schema.SiteGeneralResp{}
resp = &schema.SiteGeneralResp{CheckUpdate: true}
if err = s.GetSiteInfoByType(ctx, constant.SiteTypeGeneral, resp); err != nil {
return nil, err
}
+1
View File
@@ -30,6 +30,7 @@ export const DRAFT_TIMESIGH_STORAGE_KEY = '|_a_t_s_|';
export const QUESTIONS_ORDER_STORAGE_KEY = '_a_qok_';
export const DEFAULT_THEME = 'system';
export const ADMIN_PRIVILEGE_CUSTOM_LEVEL = 99;
export const SKELETON_SHOW_TIME = 1000;
export const USER_AGENT_NAMES = {
SegmentFault: 'SegmentFault',
+3 -1
View File
@@ -250,7 +250,7 @@ export interface QuestionDetailRes {
}
export interface AnswersReq extends Paging {
order?: 'default' | 'updated';
order?: 'default' | 'updated' | 'created';
question_id: string;
}
@@ -347,6 +347,8 @@ export interface AdminSettingsGeneral {
description: string;
site_url: string;
contact_email: string;
check_update: boolean;
permalink?: number;
}
export interface HelmetBase {
+4 -1
View File
@@ -36,6 +36,7 @@ import {
Icon,
} from '@/components';
import * as Type from '@/common/interface';
import { useSkeletonControl } from '@/hooks';
export const QUESTION_ORDER_KEYS: Type.QuestionOrderBy[] = [
'active',
@@ -59,11 +60,13 @@ const QuestionList: FC<Props> = ({
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const [urlSearchParams] = useSearchParams();
const { isSkeletonShow } = useSkeletonControl(isLoading);
const curOrder =
order || urlSearchParams.get('order') || QUESTION_ORDER_KEYS[0];
const curPage = Number(urlSearchParams.get('page')) || 1;
const pageSize = 20;
const count = data?.count || 0;
return (
<div>
<div className="mb-3 d-flex flex-wrap justify-content-between">
@@ -80,7 +83,7 @@ const QuestionList: FC<Props> = ({
/>
</div>
<ListGroup className="rounded-0">
{isLoading ? (
{isSkeletonShow ? (
<QuestionListLoader />
) : (
data?.list?.map((li) => {
+2
View File
@@ -29,6 +29,7 @@ import useLoginRedirect from './useLoginRedirect';
import usePromptWithUnload from './usePrompt';
import useActivationEmailModal from './useActivationEmailModal';
import useCaptchaModal from './useCaptchaModal';
import useSkeletonControl from './useSkeletonControl';
export {
useTagModal,
@@ -43,4 +44,5 @@ export {
usePromptWithUnload,
useActivationEmailModal,
useCaptchaModal,
useSkeletonControl,
};
+61
View File
@@ -0,0 +1,61 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useRef, useState } from 'react';
import { SKELETON_SHOW_TIME } from '@/common/constants';
/**
* @param needShowFirst whether the skeleton should show at first
*
* Why need 'needShowFirst' param?
* Sometimes we need skeleton screens to take up space in the dom from the start
*
* If you set the 'needShowFirst' param as false, If the interface time is too short,
* the skeleton screen will not be displayed, which can reduce the time occupation
*/
const useSkeletonControl = (isLoading: boolean) => {
const [isSkeletonShow, setIsSkeletonShow] = useState(false);
const timer = useRef<NodeJS.Timeout | null>(null);
const openSkeleton = () => {
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(() => {
setIsSkeletonShow(true);
}, SKELETON_SHOW_TIME);
};
const closeSkeleton = () => {
clearTimeout(timer.current as NodeJS.Timeout);
setIsSkeletonShow(false);
};
useEffect(() => {
if (isLoading) {
openSkeleton();
} else {
closeSkeleton();
}
}, [isLoading]);
return { isSkeletonShow };
};
export default useSkeletonControl;
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type * as Type from '@/common/interface';
import { siteInfoStore } from '@/stores';
const { gt, gte } = require('semver');
@@ -33,6 +34,7 @@ interface IProps {
const HealthStatus: FC<IProps> = ({ data }) => {
const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' });
const { version, remote_version } = data.version_info || {};
const { siteInfo } = siteInfoStore();
let isLatest = false;
let hasNewerVersion = false;
if (version && remote_version) {
@@ -65,7 +67,7 @@ const HealthStatus: FC<IProps> = ({ data }) => {
{t('update_to')} {remote_version}
</a>
)}
{!isLatest && !remote_version && (
{!isLatest && !remote_version && siteInfo.check_update && (
<a
className="ms-1 badge rounded-pill text-bg-danger"
target="_blank"
+13
View File
@@ -69,6 +69,11 @@ const General: FC = () => {
title: t('contact_email.label'),
description: t('contact_email.text'),
},
check_update: {
type: 'boolean',
title: t('check_update.label'),
default: true,
},
},
};
const uiSchema: UISchema = {
@@ -107,6 +112,12 @@ const General: FC = () => {
},
},
},
check_update: {
'ui:widget': 'switch',
'ui:options': {
label: t('check_update.text'),
},
},
};
const [formData, setFormData] = useState<Type.FormDataType>(
initFormData(schema),
@@ -121,6 +132,7 @@ const General: FC = () => {
short_description: formData.short_description.value,
site_url: formData.site_url.value,
contact_email: formData.contact_email.value,
check_update: formData.check_update.value,
};
updateGeneralSetting(reqParams)
@@ -135,6 +147,7 @@ const General: FC = () => {
formData.short_description.value = res.short_description;
formData.site_url.value = res.site_url;
formData.contact_email.value = res.contact_email;
formData.check_update.value = res.check_update;
}
setFormData({ ...formData });
@@ -36,6 +36,10 @@ const sortBtns = [
name: 'newest',
sort: 'updated',
},
{
name: 'oldest',
sort: 'created',
},
];
const Index: FC<Props> = ({ count = 0, order = 'default' }) => {
@@ -52,7 +56,13 @@ const Index: FC<Props> = ({ count = 0, order = 'default' }) => {
</h5>
<QueryGroup
data={sortBtns}
currentSort={order === 'updated' ? 'newest' : 'score'}
currentSort={
order === 'updated'
? 'newest'
: order === 'created'
? 'oldest'
: 'score'
}
i18nKeyPrefix="question_detail.answers"
/>
</div>
+4 -3
View File
@@ -30,7 +30,7 @@ import { useTranslation } from 'react-i18next';
import { Pagination, CustomSidebar } from '@/components';
import { loggedUserInfoStore, toastStore } from '@/stores';
import { scrollToElementTop, scrollToDocTop } from '@/utils';
import { usePageTags, usePageUsers } from '@/hooks';
import { usePageTags, usePageUsers, useSkeletonControl } from '@/hooks';
import type {
ListResult,
QuestionDetailRes,
@@ -68,6 +68,7 @@ const Index = () => {
const order = urlSearch.get('order') || '';
const [question, setQuestion] = useState<QuestionDetailRes | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const { isSkeletonShow } = useSkeletonControl(isLoading);
const [answers, setAnswers] = useState<ListResult<AnswerItem>>({
count: -1,
list: [],
@@ -92,7 +93,7 @@ const Index = () => {
const requestAnswers = async () => {
const res = await getAnswers({
order: order === 'updated' ? order : 'default',
order: order === 'updated' || order === 'created' ? order : 'default',
question_id: qid,
page: 1,
page_size: 999,
@@ -239,7 +240,7 @@ const Index = () => {
<Row className="questionDetailPage pt-4 mb-5">
<Col className="page-main flex-auto">
{question?.operation?.level && <Alert data={question.operation} />}
{isLoading ? (
{isSkeletonShow ? (
<ContentLoader />
) : (
<Question
+3 -2
View File
@@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { usePageTags, useCaptchaModal } from '@/hooks';
import { usePageTags, useCaptchaModal, useSkeletonControl } from '@/hooks';
import { Pagination } from '@/components';
import { getSearchResult } from '@/services';
import type { SearchParams, SearchRes } from '@/common/interface';
@@ -43,6 +43,7 @@ const Index = () => {
const q = searchParams.get('q') || '';
const order = searchParams.get('order') || 'active';
const [isLoading, setIsLoading] = useState(false);
const { isSkeletonShow } = useSkeletonControl(isLoading);
const [data, setData] = useState<SearchRes>({
count: 0,
list: [],
@@ -102,7 +103,7 @@ const Index = () => {
<Head data={extra} />
<SearchHead sort={order} count={count} />
<ListGroup className="rounded-0 mb-5">
{isLoading ? (
{isSkeletonShow ? (
<ListLoader />
) : (
list?.map((item) => {
+6 -2
View File
@@ -22,7 +22,7 @@ import { Row, Col, Card, Button, Form, Stack } from 'react-bootstrap';
import { useSearchParams, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTags } from '@/hooks';
import { usePageTags, useSkeletonControl } from '@/hooks';
import { Tag, Pagination, QueryGroup, TagsLoader } from '@/components';
import { formatCount, escapeRemove } from '@/utils';
import { tryNormalLogged } from '@/utils/guard';
@@ -52,6 +52,8 @@ const Tags = () => {
...(sort ? { query_cond: sort } : {}),
});
const { isSkeletonShow } = useSkeletonControl(isLoading);
const handleChange = (e) => {
setSearchTag(e.target.value);
};
@@ -67,9 +69,11 @@ const Tags = () => {
mutate();
});
};
usePageTags({
title: t('tags', { keyPrefix: 'page_title' }),
});
return (
<Row className="py-4 mb-4">
<Col xxl={12}>
@@ -106,7 +110,7 @@ const Tags = () => {
<Col className="mt-4" xxl={12}>
<Row>
{isLoading ? (
{isSkeletonShow ? (
<TagsLoader />
) : (
tags?.list?.map((tag) => (
+1
View File
@@ -50,6 +50,7 @@ const siteInfo = create<SiteInfoType>((set) => ({
short_description: '',
site_url: '',
contact_email: '',
check_update: true,
permalink: 1,
},
users: defaultUsersConf,