576 lines
19 KiB
TypeScript
576 lines
19 KiB
TypeScript
import React, {useCallback, useEffect, useMemo, useState} from "react";
|
||
import Icon from "@ant-design/icons";
|
||
import {useDebounceFn, useSetState} from "ahooks";
|
||
import {Flex, Space} from "antd";
|
||
import clsx from "clsx"
|
||
|
||
import {ProofreadStateEnum,} from "@/service/types/document.ts";
|
||
import {showToast} from "@/components/messages/Modal.tsx";
|
||
|
||
import './editor.less'
|
||
|
||
import {IconCheckOutline, IconMinus, IconRollback, IconRollbackCircle, IconRollbackOld} from "./icons.tsx";
|
||
import {ActionKey, ProofreadItem} from "./proofread-item.tsx";
|
||
|
||
import {IconLogo} from "./images/logo.tsx";
|
||
import {parseV3ProofreadResult} from "@/core/proofread-util.ts";
|
||
import {V3Enums} from "@/types/v3/enums.ts";
|
||
import {stringIsEmpty} from "@/core/string-utils.ts";
|
||
import {useBridge} from "@/core/bridge.ts";
|
||
import {CACHE_DATA} from "@/core/cache.ts";
|
||
import {process} from "@/service/api/v3/document.ts";
|
||
import {VirtualListItem} from "@/pages/proofread/components/virtual-list-item.tsx";
|
||
|
||
type ProofreadPanelProps = {
|
||
children?: React.ReactNode;
|
||
sentences: V3.ProofreadSentence[];
|
||
proofreadLoading?: boolean
|
||
onUpdateProofreadItem: (item: V3.ProofreadCorrect, action?: 'process' | 'redo') => void;
|
||
onSelectProofreadItem: (item: V3.ProofreadCorrect | null) => void;
|
||
activeProofreadItemId?: number;
|
||
disabledProofreadItemList: number[];
|
||
onStartProofread: () => Promise<void> | void;
|
||
selectedProofreadItemId?: number;
|
||
selectedAcceptStatus?: 1 | 2 | 3;
|
||
userinfo?: Account;
|
||
histories: V3.ProcessAction[];
|
||
setHistories: (values: V3.ProcessAction[]) => void;
|
||
onStateChange?: (state: { activeItemId: number; acceptState: AcceptStatus; }) => void;
|
||
}
|
||
|
||
enum AcceptStatus {
|
||
NotAdopted = 1,
|
||
Adopted = 2,
|
||
Default = 3
|
||
}
|
||
|
||
|
||
const GovCheckKeys = [
|
||
V3Enums.ProofreadType.leader.key,
|
||
V3Enums.ProofreadType.position.key,
|
||
V3Enums.ProofreadType.quotation.key,
|
||
V3Enums.ProofreadType.fallen_officers.key,
|
||
]
|
||
/**
|
||
* 是否需要复核
|
||
* @param item
|
||
*/
|
||
function itemNeedReview(item:V3.ProofreadCorrect){
|
||
if(
|
||
item.tag.trim().length == 0 // not delete insert replace
|
||
&& (
|
||
item.type == V3Enums.ProofreadType.sensitive.key
|
||
|| item.type == V3Enums.ProofreadType.blacklist.key
|
||
|| GovCheckKeys.includes(item.type)
|
||
|
||
)
|
||
) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
const ProofreadPanel: React.FC<ProofreadPanelProps> = (props) => {
|
||
const {userinfo, histories, setHistories} = props
|
||
const [state, setState] = useSetState<{
|
||
// 当前选中分类
|
||
currentType: string | null;
|
||
// 默认是否展开panel
|
||
open: boolean;
|
||
adoptAll?: boolean;
|
||
ignoreAll?: boolean;
|
||
listMaxCount: number;
|
||
activeIndex: number;
|
||
activeItemId: number;
|
||
acceptState: AcceptStatus;
|
||
}>({
|
||
currentType: null,
|
||
open: true,
|
||
|
||
adoptAll: false,
|
||
ignoreAll: false,
|
||
|
||
activeIndex: -1,
|
||
activeItemId: -1,
|
||
listMaxCount: -1,
|
||
acceptState: AcceptStatus.NotAdopted
|
||
})
|
||
|
||
|
||
const [proofreadResult, setProofreadResult] = useState<V3.ParsedProofreadResult>()
|
||
|
||
useEffect(() => {
|
||
handleTypeChange(null, false)
|
||
const result = parseV3ProofreadResult(props.sentences, props.disabledProofreadItemList)
|
||
setProofreadResult(result)
|
||
}, [props.sentences, props.proofreadLoading, props.disabledProofreadItemList])
|
||
|
||
const getCurrentProofreadTypeListByAcceptStatus = (_state?: AcceptStatus, disableType = false) => {
|
||
_state = _state || state.acceptState
|
||
let list = proofreadResult?.list || [];
|
||
if (_state == AcceptStatus.NotAdopted) {
|
||
// 获取未复核的校对项
|
||
list = list.filter(s => s.isAccept == ProofreadStateEnum.Default);
|
||
} else if (_state == AcceptStatus.Adopted) {
|
||
// 获取已复核的校对项
|
||
list = list.filter(s => s.isAccept != ProofreadStateEnum.Default)
|
||
}
|
||
if (state.currentType && !disableType) {
|
||
list = list.filter(s => {
|
||
if(state.currentType == 'gov-check'){
|
||
return GovCheckKeys.includes(s.type)
|
||
}
|
||
if(state.currentType == 'error'){
|
||
return s.type != 'blacklist' && s.type != 'sensitive' && !GovCheckKeys.includes(s.type)
|
||
}
|
||
return s.type == state.currentType
|
||
})
|
||
}
|
||
return list;
|
||
}
|
||
// 当前校对数据
|
||
const currentProofreadCount = useMemo(() => {
|
||
const all = ( proofreadResult?.list || []).length;
|
||
// 全局获取待复核的校对项 避免过滤后的数据为空
|
||
const not_adopted = ( proofreadResult?.list || []).filter(s => s.isAccept == ProofreadStateEnum.Default).length;
|
||
const list = getCurrentProofreadTypeListByAcceptStatus(undefined, true) || [];
|
||
const count: V3.ProofreadResultCount = {
|
||
all,
|
||
adopted: 0,
|
||
not_adopted,
|
||
error:0,
|
||
total: not_adopted,
|
||
sensitive: 0,
|
||
blacklist: 0,
|
||
govCheck: 0,
|
||
};
|
||
list.forEach(it => {
|
||
//count.total ++;
|
||
if (it.type === 'sensitive'){
|
||
count.sensitive ++;
|
||
}else if(it.type === 'blacklist'){
|
||
count.blacklist++;
|
||
}else if(GovCheckKeys.includes(it.type)){
|
||
count.govCheck ++;
|
||
}else{
|
||
count.error ++;
|
||
}
|
||
})
|
||
count.adopted = all - count.not_adopted;
|
||
// if(state.acceptState == AcceptStatus.Adopted) {
|
||
// }else{
|
||
// count.not_adopted = count.total;
|
||
// count.adopted = all - count.not_adopted;
|
||
// }
|
||
return count;
|
||
}, [proofreadResult, state.acceptState, state.activeItemId, props.histories])
|
||
|
||
// 当前状态校对列表
|
||
const currentProofreadList = useMemo(() => {
|
||
const list = proofreadResult?.list || [];
|
||
if (!state.currentType) return list;
|
||
// Array.isArray(state.currentType?.id) ? state.currentType?.id.includes(s.type) :
|
||
return list.filter(s => {
|
||
if(state.currentType == 'error'){
|
||
return s.type != 'blacklist' && s.type != 'sensitive'
|
||
}
|
||
return s.type == state.currentType
|
||
})
|
||
}, [proofreadResult, state.currentType])
|
||
|
||
// 撤销操作
|
||
const redoAction = async () => {
|
||
if (props.sentences.length == 0 || histories.length == 0) return;
|
||
const last = histories[histories.length - 1];
|
||
// 获取最后一个历史的同一批次记录
|
||
const currentList = histories.filter(s => s.id == last.id);
|
||
if (!currentList || currentList.length == 0) return;
|
||
currentList.forEach(it => {
|
||
// 状态调整为未处理
|
||
const item = proofreadResult?.list?.find(s => s.id == it.correctId)
|
||
if (item) {
|
||
item.isAccept = ProofreadStateEnum.Default
|
||
props.onUpdateProofreadItem(item, 'redo')
|
||
}
|
||
})
|
||
setHistories(histories.filter(s => s.id != last.id));
|
||
setProofreadResult({
|
||
count: proofreadResult!.count,
|
||
list: proofreadResult!.list
|
||
})
|
||
// 将状态调整为未复核
|
||
if (state.acceptState == AcceptStatus.Adopted) {
|
||
setState({acceptState: AcceptStatus.NotAdopted})
|
||
}
|
||
// 选中第一个
|
||
const actId = currentList[0];
|
||
// 找到要选中的item的index
|
||
const item = currentProofreadList.find(s => s.id == actId.correctId)
|
||
if (item) {
|
||
centerItem(-1, item)
|
||
// setState({activeIndex: index})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取全部/类似校对项
|
||
* @param isAccept
|
||
* @param p 如果不传入则获取该状态对应类型所有校对项
|
||
*/
|
||
const getSameProofreadItems = (isAccept?: boolean, p?: V3.ProofreadCorrect) => {
|
||
const list = currentProofreadList.filter(s => {
|
||
// if (isAccept && s.type == V3Enums.ProofreadType.sensitive.key) return false;
|
||
return s.isAccept == ProofreadStateEnum.Default;
|
||
})
|
||
// 获取当前类型下所有
|
||
return p ? list.filter(s => {
|
||
return s.origin == p.origin && s.text == p.text;
|
||
}) : (
|
||
[...list]
|
||
)
|
||
}
|
||
|
||
const addToHistory = (items: V3.ProofreadCorrect[]) => {
|
||
// 根据最后一个记录,生成新的批次id
|
||
const last = histories.length == 0 ? null : histories[histories.length - 1]
|
||
const id = last ? last.id + 1 : 1;
|
||
|
||
setHistories([
|
||
...histories,
|
||
...items.map(s => ({id, correctId: s.id}))
|
||
])
|
||
setProofreadResult(proofreadResult)
|
||
}
|
||
|
||
// 批量处理
|
||
const batchProcess = (status: ProofreadStateEnum, proofread?: V3.ProofreadCorrect) => {
|
||
const isAccept = status == ProofreadStateEnum.Accept;
|
||
const list = getSameProofreadItems(isAccept, proofread);
|
||
if (list.length > 0) {
|
||
const ids: number[] = [];
|
||
// 设置 状态
|
||
list.map(s => {
|
||
ids.push(s.id)
|
||
// 处理以下复核情况
|
||
s.isAccept = isAccept && itemNeedReview(s) ? ProofreadStateEnum.Review :status;
|
||
props.onUpdateProofreadItem(s, 'process');
|
||
return s;
|
||
})
|
||
addToHistory(list)
|
||
process(CACHE_DATA.DocumentID, {
|
||
ids,
|
||
action: status
|
||
})
|
||
// 采用随机数 避免默认值均为-1 导致选中项不更新
|
||
const activeItemId = 0 - Math.ceil(Math.random() * 10)
|
||
setTimeout(()=>{
|
||
setState({activeItemId})
|
||
},20)
|
||
}
|
||
}
|
||
|
||
// 处理全部
|
||
const handleProcessAll = (action: ActionKey) => {
|
||
return () => {
|
||
if (props.sentences.length == 0) return;
|
||
batchProcess(action == 'acceptAll' ? ProofreadStateEnum.Accept : ProofreadStateEnum.Ignore)
|
||
}
|
||
}
|
||
|
||
|
||
// 让当前选中的 item 居中
|
||
const centerItem = (activeIndex: number, item: V3.ProofreadCorrect, showInCenter = true) => {
|
||
if (item.id == state.activeItemId) return;
|
||
setState((origin) => ({
|
||
...origin,
|
||
activeIndex,
|
||
activeItemId: item.id
|
||
}))
|
||
if (showInCenter) {
|
||
// 将提示显示在视图中间
|
||
const div = document.querySelector(`div.proofread-item-${item.id}`)
|
||
if (div) {
|
||
div.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'center',
|
||
inline: 'nearest' // 水平对齐方式
|
||
})
|
||
}
|
||
}
|
||
//
|
||
props.onSelectProofreadItem(item);
|
||
}
|
||
useEffect(() => {
|
||
if (props.selectedProofreadItemId) {
|
||
const index = currentProofreadList.filter(s => (state.acceptState == AcceptStatus.Default
|
||
|| (state.acceptState == AcceptStatus.Adopted && s.isAccept != ProofreadStateEnum.Default)
|
||
|| (state.acceptState == AcceptStatus.NotAdopted && s.isAccept == ProofreadStateEnum.Default)
|
||
)).findIndex(s => s.id == props.selectedProofreadItemId)
|
||
if (index >= 0) {
|
||
centerItem(index, currentProofreadList[index])
|
||
}
|
||
setState({activeItemId: props.selectedProofreadItemId})
|
||
}
|
||
}, [props.selectedProofreadItemId])
|
||
useEffect(() => {
|
||
if (props.selectedAcceptStatus) {
|
||
setState({
|
||
acceptState: props.selectedAcceptStatus
|
||
})
|
||
}
|
||
}, [props.selectedAcceptStatus])
|
||
// 处理校对项
|
||
const handleItemAction = async (key: ActionKey, item: V3.ProofreadCorrect, index: number) => {
|
||
try {
|
||
if (key == 'click') {
|
||
if (item) {
|
||
setState({activeIndex: index, activeItemId: item.id})
|
||
}
|
||
centerItem(index, item, false)
|
||
return;
|
||
} else if (key == 'addToLexicon') {
|
||
// 添加到词库
|
||
showToast('添加词库成功', "success")
|
||
} else {
|
||
if (key == 'ignoreAll' || key == 'acceptAll') { // 同类型处理
|
||
batchProcess(key == 'acceptAll' ? ProofreadStateEnum.Accept : ProofreadStateEnum.Ignore, item)
|
||
} else {
|
||
if (key == 'ignore') { // 忽略
|
||
item.isAccept = ProofreadStateEnum.Ignore;
|
||
} else if (key == 'accept') { // 采纳
|
||
item.isAccept = ProofreadStateEnum.Accept;
|
||
} else if (key == 'review') { // 复核
|
||
item.isAccept = ProofreadStateEnum.Review;
|
||
}
|
||
props.onUpdateProofreadItem(item, 'process')
|
||
|
||
addToHistory([item])
|
||
process(CACHE_DATA.DocumentID, {
|
||
ids: [item.id],
|
||
action: item.isAccept
|
||
}).catch(console.log)
|
||
// 自动定位下一个
|
||
const currentList = state.acceptState == AcceptStatus.Default ? currentProofreadList : currentProofreadList.filter(s => (state.acceptState == AcceptStatus.Default
|
||
|| (state.acceptState == AcceptStatus.Adopted && s.isAccept != ProofreadStateEnum.Default)
|
||
|| (state.acceptState == AcceptStatus.NotAdopted && s.isAccept == ProofreadStateEnum.Default)
|
||
));
|
||
if (currentList.length == 0) {
|
||
setTimeout(() => {
|
||
setState((prevState) => ( {
|
||
...prevState,
|
||
activeIndex: -1,
|
||
activeItemId: 0 - Math.ceil(Math.random() * 100),
|
||
}))
|
||
}, 20)
|
||
return;
|
||
}
|
||
const _idx = index >= currentList.length ? index - 1 : (state.acceptState == AcceptStatus.Default ? index + 1 : index)
|
||
const _it = currentList[_idx];
|
||
setTimeout(() => {
|
||
centerItem(_idx, _it,false)
|
||
}, 20)
|
||
|
||
}
|
||
}
|
||
} catch (e) { // 处理错误了
|
||
console.log('process item action error:', (e as Error).message)
|
||
}
|
||
}
|
||
|
||
|
||
// 切换类型
|
||
const handleTypeChange = (currentType: string | null, resetIndex = true) => {
|
||
setState(_prev => ({
|
||
currentType: _prev.currentType == currentType ? null : currentType,
|
||
activeIndex: resetIndex ? -1 : _prev.activeIndex
|
||
}))
|
||
window.scrollTo({
|
||
top: 0,
|
||
behavior: 'smooth'
|
||
})
|
||
}
|
||
const {bridge} = useBridge();
|
||
const focusPane = useDebounceFn(() => bridge?.Focus(), {wait: 200,})
|
||
// Focus
|
||
const movePrevOrNextProofreadItem = (action: 'prev' | 'next') => {
|
||
setState(state => {
|
||
// 方向键移动
|
||
let list = getCurrentProofreadTypeListByAcceptStatus() || [];
|
||
if (list.length == 0) return state;
|
||
if (!stringIsEmpty(state.currentType)) {
|
||
list = list.filter(s => s.type == state.currentType)
|
||
}
|
||
// 当前选中项的索引
|
||
const index = list.findIndex(s => s.id == state.activeItemId)
|
||
const maxCount = list.length;
|
||
if (maxCount == 0
|
||
|| (action == 'next' && index >= maxCount - 1)
|
||
|| (action == 'prev' && index <= 0)
|
||
) {
|
||
return state;
|
||
}
|
||
const activeIndex = index + (action == 'next' ? 1 : -1)
|
||
const item = list[activeIndex]
|
||
const div = document.querySelector(`div.proofread-item-${item.id}`)
|
||
if (div) {
|
||
div.scrollIntoView({behavior: 'smooth', block: 'center',inline: 'nearest'})
|
||
}
|
||
props.onSelectProofreadItem(item);
|
||
setTimeout(focusPane.run, 50);
|
||
return {
|
||
...state,
|
||
activeIndex,
|
||
activeItemId: item.id
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleUpAndDownEvent = useCallback((e: KeyboardEvent) => {
|
||
const key = e.key.toLowerCase();
|
||
if (key == 'arrowup' || key == 'arrowdown') {
|
||
console.log('handleUpAndDownEvent=>', proofreadResult)
|
||
if (proofreadResult) {
|
||
movePrevOrNextProofreadItem(key == 'arrowup' ? 'prev' : 'next')
|
||
}
|
||
}
|
||
}, [proofreadResult, state])
|
||
|
||
useEffect(() => {
|
||
// 绑定键盘事件
|
||
document.documentElement.addEventListener('keyup', handleUpAndDownEvent, false)
|
||
return () => {
|
||
document.documentElement.removeEventListener('keyup', handleUpAndDownEvent, false)
|
||
}
|
||
}, [handleUpAndDownEvent]);
|
||
|
||
|
||
const currentProofreadTotal = useMemo(() => {
|
||
//const list = getCurrentProofreadTypeListByAcceptStatus(undefined,true) || [];
|
||
return currentProofreadCount.total;
|
||
// return proofreadResult?.count.total && proofreadResult?.count.total > 0 ? proofreadResult?.count.total : 0
|
||
}, [currentProofreadCount])
|
||
const _currentList = useMemo(() => {
|
||
return getCurrentProofreadTypeListByAcceptStatus() || []
|
||
}, [currentProofreadList, state.acceptState, state.activeItemId])
|
||
|
||
const handleTabChange = (key: AcceptStatus) => {
|
||
let activeItemId = -1;
|
||
if (key == AcceptStatus.Adopted) {
|
||
// 如果切换到已复核 则自动定位到最后一个
|
||
if (histories.length > 0) {
|
||
// const list = getCurrentProofreadTypeListByAcceptStatus(AcceptStatus.Adopted)
|
||
// if(list){
|
||
// const last = list.length > 0 ? list[list.length - 1] : null
|
||
// if(last) {
|
||
// activeItemId = last.id
|
||
// }
|
||
// }
|
||
const last = histories[histories.length - 1];
|
||
console.log('---last', last)
|
||
activeItemId = last.correctId;
|
||
bridge?.SelectMarkById(activeItemId, CACHE_DATA.DocumentID)
|
||
}
|
||
}
|
||
setState({acceptState: key, activeItemId})
|
||
handleTypeChange(null)
|
||
props.onStateChange?.({acceptState: key, activeItemId})
|
||
CACHE_DATA.selectStatus = key
|
||
// 如果是
|
||
}
|
||
|
||
return (<div className={'proofread-panel-container v3'}>
|
||
<div className="panel-header system-font">
|
||
<Flex align="center">
|
||
<div style={{fontSize: 50, marginRight: 30}}>
|
||
<IconLogo style={{display: 'block'}}/>
|
||
</div>
|
||
<Flex justify={'center'} className={'operation-buttons'}>
|
||
<Space style={{margin: 10}}>
|
||
<button
|
||
disabled={props.proofreadLoading || state.acceptState == AcceptStatus.Adopted || props.sentences.length == 0 || currentProofreadTotal == 0}
|
||
className={'btn-process'}
|
||
onClick={handleProcessAll('acceptAll')}>
|
||
<div className="btn-icon"><Icon component={IconCheckOutline}/></div>
|
||
<div>全部采纳</div>
|
||
</button>
|
||
<button
|
||
disabled={props.proofreadLoading || state.acceptState == AcceptStatus.Adopted || props.sentences.length == 0 || currentProofreadTotal == 0}
|
||
className={'btn-process'}
|
||
onClick={handleProcessAll('ignore')}>
|
||
<div className="btn-icon"><Icon component={IconMinus}/></div>
|
||
<div>全部忽略</div>
|
||
</button>
|
||
<button
|
||
className="btn-process"
|
||
disabled={histories.length == 0 || props.sentences.length == 0 || (currentProofreadTotal == 0 && histories.length == 0)}
|
||
onClick={redoAction}>
|
||
<div className="btn-icon"><Icon component={IconRollbackCircle}/></div>
|
||
<div>上一步</div>
|
||
</button>
|
||
</Space>
|
||
</Flex>
|
||
{props.children}
|
||
</Flex>
|
||
<div className="state-filter-tab">
|
||
<div className="tab-wrapper">
|
||
{[
|
||
{title: '待复核', key: AcceptStatus.NotAdopted, count: currentProofreadCount.not_adopted},
|
||
{title: '已复核', key: AcceptStatus.Adopted,count: currentProofreadCount.adopted},
|
||
{title: '全部', key: AcceptStatus.Default,count: currentProofreadCount.all}
|
||
].map((it, idx) => (
|
||
<div
|
||
key={idx}
|
||
onClick={() => {handleTabChange(it.key)}}
|
||
className={clsx('tab-item d-flex justify-center item-center', {active: state.acceptState == it.key})}
|
||
>
|
||
<span>{it.title}</span>
|
||
<span className="tab-item-count" style={{marginLeft:5}}>{props.proofreadLoading?'...':it.count}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={`proofread-list-wrapper ${proofreadResult ? 'has-result' : ''}`}>
|
||
<div className="proofread-result">
|
||
|
||
<div className={'proofread-list'}>
|
||
<div style={{paddingTop: 2}}>
|
||
{_currentList.map((it, index) => (
|
||
<VirtualListItem key={it.id} id={it.id} index={index}>
|
||
<ProofreadItem
|
||
it={it}
|
||
className={`proofread-index-${index}`}
|
||
index={index}
|
||
last={false}
|
||
proofreading={props.proofreadLoading}
|
||
selected={state.activeItemId == it.id}
|
||
onAction={key => handleItemAction(key, it, index)}
|
||
previewMode={false}
|
||
/>
|
||
</VirtualListItem>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="types system-font">
|
||
<div className="container">
|
||
{
|
||
[
|
||
{title: '错误', key: 'error', count: currentProofreadCount.error},
|
||
{title: '敏感词', key: 'sensitive', count: currentProofreadCount.sensitive},
|
||
{title: '黑名单', key: 'blacklist', count: currentProofreadCount.blacklist},
|
||
{title: '政务', key: 'gov-check', count: currentProofreadCount.govCheck,color:'hsla(179, 100%, 70%, 1)'}
|
||
].map((it, idx) => (
|
||
it.count <= 0 ? null : <div key={idx} onClick={() => handleTypeChange(it.key)}
|
||
className={`item ${state.currentType == it.key ? 'active' : ''}`}>
|
||
<div className="count" style={it.color?{color:it.color}:{}}>{props.proofreadLoading ? '...' : it.count}</div>
|
||
<div className="text">{it.title}</div>
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>)
|
||
}
|
||
export default ProofreadPanel; |