mirror of https://gitee.com/answerdev/answer.git
feat: add timeline page
This commit is contained in:
commit
9359f27729
|
@ -20,6 +20,5 @@
|
|||
Thumbs*.db
|
||||
tmp
|
||||
vendor/
|
||||
.husky
|
||||
/answer-data/
|
||||
/answer
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
_
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd ui && pnpm commitlint --edit $1 --config commitlint.config.js
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd ui && npm run pre-commit
|
|
@ -517,6 +517,7 @@ ui:
|
|||
setting: Settings
|
||||
logout: Log out
|
||||
admin: Admin
|
||||
review: Review
|
||||
search:
|
||||
placeholder: Search
|
||||
footer:
|
||||
|
@ -726,6 +727,9 @@ ui:
|
|||
logout: Log out
|
||||
verify: Verify
|
||||
add_question: Add question
|
||||
approve: Approve
|
||||
reject: Reject
|
||||
skip: Skip
|
||||
search:
|
||||
title: Search Results
|
||||
keywords: Keywords
|
||||
|
@ -1173,3 +1177,29 @@ ui:
|
|||
invalid: is invalid
|
||||
btn_submit: Save
|
||||
not_found_props: "Required property {{ key }} not found."
|
||||
page_review:
|
||||
review: Reivew
|
||||
proposed: proposed
|
||||
question_edit: Question edit
|
||||
answer_edit: Answer edit
|
||||
tag_edit: Tag edit
|
||||
edit_summary: Edit summary
|
||||
empty: No review tasks left.
|
||||
timeline:
|
||||
undeleted: undeleted
|
||||
deleted: deleted
|
||||
downvote: downvote
|
||||
upvote: upvote
|
||||
accept: accept
|
||||
cancelled: cancelled
|
||||
commented: commented
|
||||
rollback: rollback
|
||||
edited: edited
|
||||
answered: answered
|
||||
asked: asked
|
||||
closed: closed
|
||||
reopened: reopened
|
||||
created: created
|
||||
title: "History for"
|
||||
show_votes: "Show votes"
|
||||
n_or_a: N/A
|
||||
|
|
|
@ -23,8 +23,9 @@ module.exports = {
|
|||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint'],
|
||||
plugins: ['react', '@typescript-eslint', 'prettier'],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'no-console': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"*.{js,jsx,ts,tsx}": ["eslint --cache --fix"],
|
||||
"*.{js,jsx,less,md,json}": ["prettier --write"],
|
||||
"*.{js,jsx,scss,md,json}": ["prettier --write"],
|
||||
"*.ts?(x)": ["prettier --parser=typescript --write"]
|
||||
}
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
module.exports = {
|
||||
extends: ['@commitlint/routes-conventional'],
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
};
|
||||
|
|
|
@ -9,7 +9,8 @@
|
|||
"lint": "eslint . --cache --fix --ext .ts,.tsx",
|
||||
"prettier": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
|
||||
"prepare": "cd .. && husky install",
|
||||
"preinstall": "node ./scripts/preinstall.js"
|
||||
"preinstall": "node ./scripts/preinstall.js",
|
||||
"pre-commit": "lint-staged"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
@ -39,6 +40,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.0.3",
|
||||
"@commitlint/config-conventional": "^17.2.0",
|
||||
"@fullhuman/postcss-purgecss": "^4.1.3",
|
||||
"@testing-library/dom": "^8.17.1",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
|
|
|
@ -2,6 +2,7 @@ lockfileVersion: 5.4
|
|||
|
||||
specifiers:
|
||||
'@commitlint/cli': ^17.0.3
|
||||
'@commitlint/config-conventional': ^17.2.0
|
||||
'@fullhuman/postcss-purgecss': ^4.1.3
|
||||
'@testing-library/dom': ^8.17.1
|
||||
'@testing-library/jest-dom': ^4.2.4
|
||||
|
@ -93,6 +94,7 @@ dependencies:
|
|||
|
||||
devDependencies:
|
||||
'@commitlint/cli': 17.1.2
|
||||
'@commitlint/config-conventional': 17.2.0
|
||||
'@fullhuman/postcss-purgecss': 4.1.3_postcss@8.4.16
|
||||
'@testing-library/dom': 8.18.1
|
||||
'@testing-library/jest-dom': 4.2.4
|
||||
|
@ -1421,6 +1423,13 @@ packages:
|
|||
- '@swc/wasm'
|
||||
dev: true
|
||||
|
||||
/@commitlint/config-conventional/17.2.0:
|
||||
resolution: {integrity: sha512-g5hQqRa80f++SYS233dbDSg16YdyounMTAhVcmqtInNeY/GF3aA4st9SVtJxpeGrGmueMrU4L+BBb+6Vs5wrcg==}
|
||||
engines: {node: '>=v14'}
|
||||
dependencies:
|
||||
conventional-changelog-conventionalcommits: 5.0.0
|
||||
dev: true
|
||||
|
||||
/@commitlint/config-validator/17.1.0:
|
||||
resolution: {integrity: sha512-Q1rRRSU09ngrTgeTXHq6ePJs2KrI+axPTgkNYDWSJIuS1Op4w3J30vUfSXjwn5YEJHklK3fSqWNHmBhmTR7Vdg==}
|
||||
engines: {node: '>=v14'}
|
||||
|
@ -3765,6 +3774,15 @@ packages:
|
|||
q: 1.5.1
|
||||
dev: true
|
||||
|
||||
/conventional-changelog-conventionalcommits/5.0.0:
|
||||
resolution: {integrity: sha512-lCDbA+ZqVFQGUj7h9QBKoIpLhl8iihkO0nCTyRNzuXtcd7ubODpYB04IFy31JloiJgG0Uovu8ot8oxRzn7Nwtw==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
compare-func: 2.0.0
|
||||
lodash: 4.17.21
|
||||
q: 1.5.1
|
||||
dev: true
|
||||
|
||||
/conventional-commits-parser/3.2.4:
|
||||
resolution: {integrity: sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
|
@ -557,3 +557,10 @@ export const TIMEZONES = [
|
|||
},
|
||||
];
|
||||
export const DEFAULT_TIMEZONE = 'UTC+0';
|
||||
|
||||
export const TIMELINE_NORMAL_ACTIVITY_TYPE = [
|
||||
'undeleted',
|
||||
'deleted',
|
||||
'downvote',
|
||||
'upvote',
|
||||
];
|
||||
|
|
|
@ -124,6 +124,7 @@ export interface UserInfoRes extends UserInfoBase {
|
|||
*/
|
||||
mail_status: number;
|
||||
language: string;
|
||||
is_admin: boolean;
|
||||
e_mail?: string;
|
||||
[prop: string]: any;
|
||||
}
|
||||
|
@ -373,3 +374,36 @@ export interface AdminDashboard {
|
|||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimelineReq {
|
||||
object_type: string;
|
||||
show_vote: boolean;
|
||||
object_id?: string;
|
||||
tag_slug_name?: string;
|
||||
}
|
||||
|
||||
export interface TimelineItem {
|
||||
activity_id: number;
|
||||
revision_id: number;
|
||||
created_at: number;
|
||||
activity_type: string;
|
||||
username: string;
|
||||
user_display_name: string;
|
||||
comment: string;
|
||||
object_id: string;
|
||||
object_type: string;
|
||||
cancelled: boolean;
|
||||
cancelled_at: any;
|
||||
}
|
||||
|
||||
export interface TimelineObject {
|
||||
title: string;
|
||||
object_type: string;
|
||||
question_id: string;
|
||||
answer_id: string;
|
||||
}
|
||||
|
||||
export interface TimelineRes {
|
||||
object_info: TimelineObject;
|
||||
timeline: TimelineItem[];
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ interface Props {
|
|||
data: any;
|
||||
showAvatar?: boolean;
|
||||
avatarSize?: string;
|
||||
showReputation?: boolean;
|
||||
avatarSearchStr?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
@ -18,6 +19,7 @@ const Index: FC<Props> = ({
|
|||
avatarSize = '20px',
|
||||
className = 'fs-14',
|
||||
avatarSearchStr = 's=48',
|
||||
showReputation = true,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`text-secondary ${className}`}>
|
||||
|
@ -47,9 +49,11 @@ const Index: FC<Props> = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
{showReputation && (
|
||||
<span className="fw-bold" title="Reputation">
|
||||
{formatCount(data?.rank)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
import { FC, memo } from 'react';
|
||||
|
||||
import { Tag } from '@/components';
|
||||
import { diffText } from '@/utils';
|
||||
|
||||
interface Props {
|
||||
currentData: Record<string, any>;
|
||||
prevData?: Record<string, any>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Index: FC<Props> = ({ currentData, prevData, className = '' }) => {
|
||||
if (!currentData?.content) return null;
|
||||
|
||||
let tag;
|
||||
if (prevData?.tags) {
|
||||
const addTags = currentData.tags.filter(
|
||||
(c) => !prevData?.tags?.find((p) => p.slug_name === c.slug_name),
|
||||
);
|
||||
|
||||
let deleteTags = prevData?.tags
|
||||
.filter(
|
||||
(c) => !currentData?.tags.find((p) => p.slug_name === c.slug_name),
|
||||
)
|
||||
.map((v) => ({ ...v, state: 'delete' }));
|
||||
|
||||
deleteTags = deleteTags?.map((v) => {
|
||||
const index = prevData?.tags?.findIndex(
|
||||
(c) => c.slug_name === v.slug_name,
|
||||
);
|
||||
return {
|
||||
...v,
|
||||
pre_index: index,
|
||||
};
|
||||
});
|
||||
|
||||
tag = currentData.tags.map((item) => {
|
||||
const find = addTags.find((c) => c.slug_name === item.slug_name);
|
||||
if (find) {
|
||||
return {
|
||||
...find,
|
||||
state: 'add',
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
deleteTags.forEach((v) => {
|
||||
tag.splice(v.pre_index, 0, v);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h5
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: diffText(currentData.title, prevData?.title),
|
||||
}}
|
||||
className="mb-3"
|
||||
/>
|
||||
<div className="mb-4">
|
||||
{tag.map((item) => {
|
||||
return (
|
||||
<Tag
|
||||
key={item.slug_name}
|
||||
className="me-1"
|
||||
data={item}
|
||||
textClassName={`d-inline-block review-text-${item.state}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: diffText(currentData.content, prevData?.content),
|
||||
}}
|
||||
className="pre-line"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Index);
|
|
@ -1,13 +1,15 @@
|
|||
import { FC, memo } from 'react';
|
||||
import { FC, memo, ReactNode } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
const Index: FC = () => {
|
||||
const Index: FC<{ children?: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="text-center py-5">
|
||||
{children || (
|
||||
<Trans i18nKey="personal.list_empty">
|
||||
We couldn't find anything. <br /> Try different or less specific
|
||||
keywords.
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -56,6 +56,12 @@ const Index: FC<Props> = ({ redDot, userInfo, logOut }) => {
|
|||
{userInfo?.is_admin ? (
|
||||
<Dropdown.Item href="/admin">{t('header.nav.admin')}</Dropdown.Item>
|
||||
) : null}
|
||||
{/* TODO: use review permission */}
|
||||
{userInfo?.is_admin ? (
|
||||
<Dropdown.Item href="/review">
|
||||
{t('header.nav.review')}
|
||||
</Dropdown.Item>
|
||||
) : null}
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item onClick={logOut}>
|
||||
{t('header.nav.logout')}
|
||||
|
|
|
@ -122,9 +122,6 @@ const Header: FC = () => {
|
|||
<NavLink className="nav-link" to="/tags">
|
||||
{t('header.nav.tag')}
|
||||
</NavLink>
|
||||
<NavLink className="nav-link d-none" to="/users">
|
||||
{t('header.nav.user')}
|
||||
</NavLink>
|
||||
</Nav>
|
||||
</Col>
|
||||
<hr className="hr lg-none mt-2" />
|
||||
|
|
|
@ -8,9 +8,15 @@ interface IProps {
|
|||
data: Tag;
|
||||
href?: string;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
const Index: FC<IProps> = ({ className = '', href, data }) => {
|
||||
const Index: FC<IProps> = ({
|
||||
data,
|
||||
href,
|
||||
className = '',
|
||||
textClassName = '',
|
||||
}) => {
|
||||
href ||= `/tags/${encodeURIComponent(
|
||||
data.main_tag_slug_name || data.slug_name,
|
||||
)}`.toLowerCase();
|
||||
|
@ -24,7 +30,7 @@ const Index: FC<IProps> = ({ className = '', href, data }) => {
|
|||
data.recommend && 'badge-tag-required',
|
||||
className,
|
||||
)}>
|
||||
{data.slug_name}
|
||||
<span className={textClassName}>{data.slug_name}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -27,6 +27,7 @@ import QueryGroup from './QueryGroup';
|
|||
import BrandUpload from './BrandUpload';
|
||||
import SchemaForm, { JSONSchema, UISchema, initFormData } from './SchemaForm';
|
||||
import Labels from './LabelsCard';
|
||||
import DiffContent from './DiffContent';
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
|
@ -60,5 +61,6 @@ export {
|
|||
SchemaForm,
|
||||
initFormData,
|
||||
Labels,
|
||||
DiffContent,
|
||||
};
|
||||
export type { EditorRef, JSONSchema, UISchema };
|
||||
|
|
|
@ -256,7 +256,7 @@ a {
|
|||
|
||||
.review-text-delete {
|
||||
color: #842029;
|
||||
background-color: #F8D7DA;
|
||||
background-color: #f8d7da;
|
||||
text-decoration: line-through;
|
||||
.review-text-add {
|
||||
text-decoration: none;
|
||||
|
@ -264,7 +264,22 @@ a {
|
|||
}
|
||||
|
||||
.review-text-add {
|
||||
color: #0F5132;
|
||||
background-color: #D1E7DD;
|
||||
color: #0f5132;
|
||||
background-color: #d1e7dd;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.rotate-90-deg {
|
||||
display: inline-block;
|
||||
transform: rotate(90deg);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.rotate-0-deg {
|
||||
display: inline-block;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.pre-line {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
import { FC } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Row,
|
||||
Col,
|
||||
Alert,
|
||||
Badge,
|
||||
Stack,
|
||||
Button,
|
||||
} from 'react-bootstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { BaseUserCard, FormatTime, Empty } from '@/components';
|
||||
import { loggedUserInfoStore } from '@/stores';
|
||||
|
||||
const Index: FC = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'page_review' });
|
||||
|
||||
const { user } = loggedUserInfoStore.getState();
|
||||
return (
|
||||
<Container className="pt-2 mt-4 mb-5">
|
||||
<Row>
|
||||
<Col lg={{ span: 7, offset: 1 }}>
|
||||
<h3 className="mb-4">{t('review')}</h3>
|
||||
<Alert variant="secondary">
|
||||
<Stack className="align-items-start">
|
||||
<Badge bg="secondary" className="mb-2">
|
||||
{t('question_edit')}
|
||||
</Badge>
|
||||
<Link to="/review">
|
||||
How do I test whether variable against multiple
|
||||
</Link>
|
||||
<p className="mb-0">
|
||||
{t('edit_summary')}: Editing part of the code and correcting the
|
||||
grammar.
|
||||
</p>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="horizontal"
|
||||
gap={1}
|
||||
className="align-items-baseline mt-2">
|
||||
<BaseUserCard data={user} avatarSize="24" />
|
||||
<FormatTime
|
||||
time={Date.now()}
|
||||
className="small text-secondary"
|
||||
preFix={t('proposed')}
|
||||
/>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Col>
|
||||
<Col lg={{ span: 7, offset: 1 }}>Content</Col>
|
||||
<Col lg={{ span: 7, offset: 1 }}>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Button variant="outline-primary">
|
||||
{t('approve', { keyPrefix: 'btns' })}
|
||||
</Button>
|
||||
<Button variant="outline-primary">
|
||||
{t('reject', { keyPrefix: 'btns' })}
|
||||
</Button>
|
||||
<Button variant="outline-primary">
|
||||
{t('skip', { keyPrefix: 'btns' })}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Col>
|
||||
<Col lg={{ span: 7, offset: 1 }}>
|
||||
<Empty>{t('empty')}</Empty>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
|
@ -0,0 +1,204 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { Button, Row, Col } from 'react-bootstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon, BaseUserCard, DiffContent, FormatTime } from '@/components';
|
||||
import { TIMELINE_NORMAL_ACTIVITY_TYPE } from '@/common/constants';
|
||||
import * as Type from '@/common/interface';
|
||||
|
||||
const data1 = {
|
||||
title: '不是管理员,提一个问题看看能不能编辑resserved tag?',
|
||||
tags: [
|
||||
{
|
||||
display_name: 'bug',
|
||||
slug_name: 'bug',
|
||||
recommend: true,
|
||||
reserved: false,
|
||||
},
|
||||
{
|
||||
display_name: '黄马甲',
|
||||
slug_name: '黄马甲',
|
||||
recommend: false,
|
||||
reserved: true,
|
||||
},
|
||||
{
|
||||
display_name: 'go',
|
||||
slug_name: 'go',
|
||||
recommend: false,
|
||||
reserved: false,
|
||||
},
|
||||
],
|
||||
content: `# 前言
|
||||
手写 Promise 是面试的时候大家都逃避的送命题,在学些了解后发现通过实现源码更能将新一代的异步方案理解的通透,知其然知其所以然的运用。
|
||||
|
||||
如果直接将源码贴到此处势必不能有更大的收获,下面就按实现版本来看做简要分析。
|
||||
|
||||
## 回顾 Promise
|
||||
Promise 是 CommonJS 提出来的这一种规范,有多个版本,在 ES6 当中已经纳入规范,原生支持 Promise 对象,非 ES6 环境可以用类似 Bluebird、Q 这类库来支持。
|
||||
|
||||
Promise 可以将回调变成链式调用写法,流程更加清晰,代码更加优雅,还可以批量处理异步任务。
|
||||
|
||||
简单归纳下 Promise:三个状态、两个过程、一个方法,快速记忆方法:3-2-1
|
||||
|
||||
三个状态:pending、fulfilled、rejected
|
||||
|
||||
两个过程:
|
||||
|
||||
* pending → fulfilled(resolve)
|
||||
* pending → rejected(reject)
|
||||
一个方法:then
|
||||
|
||||
当然还有其他概念,如 catch、 Promise.all/race/allSettled。`,
|
||||
};
|
||||
|
||||
const data2 = {
|
||||
title: '提一个问题看看能不能编辑 resserved tag?',
|
||||
tags: [
|
||||
{
|
||||
display_name: 'discussion',
|
||||
slug_name: 'discussion',
|
||||
recommend: true,
|
||||
reserved: false,
|
||||
},
|
||||
{
|
||||
display_name: '黄马甲',
|
||||
slug_name: '黄马甲',
|
||||
recommend: false,
|
||||
reserved: true,
|
||||
},
|
||||
{
|
||||
display_name: 'go',
|
||||
slug_name: 'go',
|
||||
recommend: false,
|
||||
reserved: false,
|
||||
},
|
||||
],
|
||||
content: `# 前言
|
||||
手写 Promise 是面试的时候大家都逃避的送命题,在学些了解后发现通过实现源码更能将新一代的异步方案理解的通透,知用。
|
||||
|
||||
## 增加的titlte
|
||||
|
||||
如果直接将源码贴到此处势必不能有更大的收获。
|
||||
|
||||
## 回顾 Promise
|
||||
Promise 是 CommonJS 规范,并且有多个版本,在 ES6 当中已经纳入规范,原生支持 Promise 对象,非 ES6 环境可以用类似 Bluebird、Q 这类库来支持。
|
||||
|
||||
Promise 可以将回调变成链式调用写法,流程更加清晰,代码更加优雅,还可以批量处理异步任务。
|
||||
|
||||
简单归纳下 Promise:三个状态、两个过程、一个方法,快速记忆方法:3-2-1
|
||||
|
||||
两个过程:
|
||||
|
||||
* pending → fulfilled(resolve)
|
||||
* pending → rejected(reject)
|
||||
一个方法:then
|
||||
`,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
data: Type.TimelineItem;
|
||||
objectInfo: Type.TimelineObject;
|
||||
source: 'question' | 'answer' | 'tag';
|
||||
isAdmin: boolean;
|
||||
}
|
||||
const Index: FC<Props> = ({
|
||||
data,
|
||||
isAdmin,
|
||||
source = 'question',
|
||||
objectInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'timeline' });
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const handleItemClick = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
<FormatTime time={data.created_at} />
|
||||
<br />
|
||||
{data.cancelled_at > 0 && <FormatTime time={data.cancelled_at} />}
|
||||
</td>
|
||||
<td>
|
||||
{(data.activity_type === 'rollback' ||
|
||||
data.activity_type === 'edited' ||
|
||||
data.activity_type === 'asked' ||
|
||||
data.activity_type === 'created' ||
|
||||
(source === 'answer' && data.activity_type === 'answered')) && (
|
||||
<Button
|
||||
onClick={handleItemClick}
|
||||
variant="link"
|
||||
className="text-body p-0 btn-no-border">
|
||||
<Icon
|
||||
name="caret-right-fill"
|
||||
className={`me-1 ${isOpen ? 'rotate-90-deg' : 'rotate-0-deg'}`}
|
||||
/>
|
||||
{t(data.activity_type)}
|
||||
</Button>
|
||||
)}
|
||||
{data.activity_type === 'accept' && (
|
||||
<Link to={`/question/${objectInfo.question_id}`}>
|
||||
{t(data.activity_type)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{source === 'question' && data.activity_type === 'answered' && (
|
||||
<Link
|
||||
to={`/question/${objectInfo.question_id}/${objectInfo.answer_id}`}>
|
||||
{t(data.activity_type)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{data.activity_type === 'commented' && (
|
||||
<Link
|
||||
to={
|
||||
data.object_type === 'answer'
|
||||
? `/question/${objectInfo.question_id}/${objectInfo.answer_id}?commentId=${data.object_id}`
|
||||
: `/question/${objectInfo.question_id}?commentId=${data.object_id}`
|
||||
}>
|
||||
{t(data.activity_type)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{TIMELINE_NORMAL_ACTIVITY_TYPE.includes(data.activity_type) && (
|
||||
<div>{t(data.activity_type)}</div>
|
||||
)}
|
||||
|
||||
{data.cancelled && (
|
||||
<div className="text-danger"> {t('cancelled')}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{data.activity_type === 'downvote' && !isAdmin ? (
|
||||
<div>{t('n_or_a')}</div>
|
||||
) : (
|
||||
<BaseUserCard
|
||||
className="fs-normal"
|
||||
data={{
|
||||
username: data.username,
|
||||
display_name: data.user_display_name,
|
||||
}}
|
||||
showAvatar={false}
|
||||
showReputation={false}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>{data.comment}</td>
|
||||
</tr>
|
||||
<tr className={isOpen ? '' : 'd-none'}>
|
||||
{/* <td /> */}
|
||||
<td colSpan={5} className="p-0 py-5">
|
||||
<Row className="justify-content-center">
|
||||
<Col xxl={8}>
|
||||
<DiffContent currentData={data1} prevData={data2} />
|
||||
</Col>
|
||||
</Row>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
|
@ -0,0 +1,140 @@
|
|||
import { FC } from 'react';
|
||||
import { Container, Row, Col, Form, Table } from 'react-bootstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { loggedUserInfoStore } from '@/stores';
|
||||
import { useTimelineData } from '@/services';
|
||||
|
||||
import HistoryItem from './components/Item';
|
||||
|
||||
// const list = [
|
||||
// {
|
||||
// activity_id: 1,
|
||||
// revision_id: 1,
|
||||
// created_at: 1669084579,
|
||||
// activity_type: 'deleted',
|
||||
// username: 'John Doe',
|
||||
// user_display_name: 'John Doe',
|
||||
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
|
||||
// object_id: '1',
|
||||
// object_type: 'question',
|
||||
// cancelled: false,
|
||||
// cancelled_at: null,
|
||||
// },
|
||||
// {
|
||||
// activity_id: 2,
|
||||
// revision_id: 2,
|
||||
// created_at: 1669084579,
|
||||
// activity_type: 'undeleted',
|
||||
// username: 'John Doe2',
|
||||
// user_display_name: 'John Doe2',
|
||||
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
|
||||
// object_id: '2',
|
||||
// object_type: 'question',
|
||||
// cancelled: false,
|
||||
// cancelled_at: null,
|
||||
// },
|
||||
// {
|
||||
// activity_id: 3,
|
||||
// revision_id: 3,
|
||||
// created_at: 1669084579,
|
||||
// activity_type: 'downvote',
|
||||
// username: 'johndoe3',
|
||||
// user_display_name: 'John Doe3',
|
||||
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
|
||||
// object_id: '3',
|
||||
// object_type: 'question',
|
||||
// cancelled: true,
|
||||
// cancelled_at: 1637021579,
|
||||
// },
|
||||
// {
|
||||
// activity_id: 4,
|
||||
// revision_id: 4,
|
||||
// created_at: 1669084579,
|
||||
// activity_type: 'rollback',
|
||||
// username: 'johndoe4',
|
||||
// user_display_name: 'John Doe4',
|
||||
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
|
||||
// object_id: '4',
|
||||
// object_type: 'question',
|
||||
// cancelled: false,
|
||||
// cancelled_at: null,
|
||||
// },
|
||||
// {
|
||||
// activity_id: 5,
|
||||
// revision_id: 5,
|
||||
// created_at: 1669084579,
|
||||
// activity_type: 'edited',
|
||||
// username: 'johndoe4',
|
||||
// user_display_name: 'John Doe4',
|
||||
// object_id: '5',
|
||||
// object_type: 'question',
|
||||
// comment: '啊撒旦法师打发房管局挥洒过短发合计干哈就撒刚发几哈',
|
||||
// cancelled: false,
|
||||
// cancelled_at: null,
|
||||
// },
|
||||
// ];
|
||||
|
||||
// const object_info = {
|
||||
// title: '问题标题,当回答时也是问题标题,当为 tag 时是 slug_name',
|
||||
// object_type: 'question', // question/answer/tag
|
||||
// question_id: 'xxxxxxxxxxxxxxxxxxx',
|
||||
// answer_id: 'xxxxxxxxxxxxxxxx',
|
||||
// };
|
||||
|
||||
const Index: FC = () => {
|
||||
const { t } = useTranslation('translation', { keyPrefix: 'timeline' });
|
||||
const { is_admin } = loggedUserInfoStore((state) => state.user);
|
||||
|
||||
const { data: timelineData } = useTimelineData({
|
||||
object_id: '10010000000000001',
|
||||
object_type: 'question',
|
||||
show_vote: false,
|
||||
});
|
||||
|
||||
console.log('timelineData=', timelineData);
|
||||
|
||||
return (
|
||||
<Container className="py-3">
|
||||
<Row className="py-3 justify-content-center">
|
||||
<Col xxl={10}>
|
||||
<h5 className="mb-4">
|
||||
{t('title')} <Link to="/">{timelineData?.object_info?.title}</Link>
|
||||
</h5>
|
||||
<Form.Check
|
||||
className="mb-4"
|
||||
type="switch"
|
||||
id="custom-switch"
|
||||
label={t('show_votes')}
|
||||
/>
|
||||
<Table hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '20%' }}>Datetime</th>
|
||||
<th style={{ width: '15%' }}>Type</th>
|
||||
<th style={{ width: '19%' }}>By</th>
|
||||
<th>Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timelineData?.timeline?.map((item) => {
|
||||
return (
|
||||
<HistoryItem
|
||||
data={item}
|
||||
objectInfo={timelineData?.object_info}
|
||||
key={item.revision_id}
|
||||
isAdmin={is_admin}
|
||||
source="question"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
|
@ -83,7 +83,7 @@ const routes: RouteNode[] = [
|
|||
return guard.activated();
|
||||
},
|
||||
},
|
||||
// users
|
||||
// for users
|
||||
{
|
||||
path: 'users/:username',
|
||||
page: 'pages/Users/Personal',
|
||||
|
@ -202,8 +202,11 @@ const routes: RouteNode[] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
path: '/revision',
|
||||
page: 'pages/Revision',
|
||||
path: '/history',
|
||||
page: 'pages/Timeline',
|
||||
guard: async () => {
|
||||
return guard.logged();
|
||||
},
|
||||
},
|
||||
// for admin
|
||||
{
|
||||
|
@ -268,6 +271,11 @@ const routes: RouteNode[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
// for review
|
||||
{
|
||||
path: 'review',
|
||||
page: 'pages/Review',
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
page: 'pages/404',
|
||||
|
|
|
@ -6,3 +6,4 @@ export * from './search';
|
|||
export * from './tag';
|
||||
export * from './settings';
|
||||
export * from './legal';
|
||||
export * from './timeline';
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import useSWR from 'swr';
|
||||
import qs from 'qs';
|
||||
|
||||
import request from '@/utils/request';
|
||||
import type * as Type from '@/common/interface';
|
||||
|
||||
export const useTimelineData = (params: Type.TimelineReq) => {
|
||||
const apiUrl = '/answer/api/v1/activity/timeline';
|
||||
const { data, error, mutate } = useSWR<Type.TimelineRes, Error>(
|
||||
`${apiUrl}?${qs.stringify(params, { skipNulls: true })}`,
|
||||
request.instance.get,
|
||||
);
|
||||
return {
|
||||
data,
|
||||
isLoading: !data && !error,
|
||||
error,
|
||||
mutate,
|
||||
};
|
||||
};
|
|
@ -1,5 +0,0 @@
|
|||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
|
@ -26,6 +26,7 @@ const initUser: UserInfoRes = {
|
|||
status: '',
|
||||
mail_status: 1,
|
||||
language: 'Default',
|
||||
is_admin: false,
|
||||
};
|
||||
|
||||
const loggedUserInfoStore = create<UserInfoStore>((set) => ({
|
||||
|
|
|
@ -166,6 +166,16 @@ function handleFormError(
|
|||
}
|
||||
|
||||
function diffText(newText: string, oldText: string): string {
|
||||
if (!newText) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!oldText) {
|
||||
return newText
|
||||
?.replace(/\n/gi, '<br>')
|
||||
?.replace(/<iframe/gi, '<iframe')
|
||||
?.replace(/<input/gi, '<input');
|
||||
}
|
||||
const diff = Diff.diffChars(newText, oldText);
|
||||
const result = diff.map((part) => {
|
||||
if (part.added) {
|
||||
|
@ -176,9 +186,9 @@ function diffText(newText: string, oldText: string): string {
|
|||
}
|
||||
return part.value;
|
||||
});
|
||||
|
||||
return result
|
||||
.join('')
|
||||
?.replace(/\n/gi, '<br>')
|
||||
?.replace(/<iframe/gi, '<iframe')
|
||||
?.replace(/<input/gi, '<input');
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue