提交 fe79c9e1 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 添加章节加载失败处理,优化章节编辑器的加载逻辑

上级 3da7f60b
...@@ -57,6 +57,7 @@ const Examine = () => { ...@@ -57,6 +57,7 @@ const Examine = () => {
const [parentId, setParentId] = useState(null) const [parentId, setParentId] = useState(null)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [chapterLoadFailed, setChapterLoadFailed] = useState(false)
const contentMd5Ref = useRef(md5('')) const contentMd5Ref = useRef(md5(''))
const chapterRequestRef = useRef(0) const chapterRequestRef = useRef(0)
const chapterSwitchingRef = useRef(false) const chapterSwitchingRef = useRef(false)
...@@ -106,6 +107,7 @@ const Examine = () => { ...@@ -106,6 +107,7 @@ const Examine = () => {
const editorRef = useRef() const editorRef = useRef()
const [html, setHtml] = useState('') const [html, setHtml] = useState('')
const [contentId, setContentId] = useState(false) const [contentId, setContentId] = useState(false)
const [loadedChapterId, setLoadedChapterId] = useState(0)
useEffect(() => { useEffect(() => {
if (!id || !userInfo?.id) { if (!id || !userInfo?.id) {
...@@ -177,6 +179,7 @@ const Examine = () => { ...@@ -177,6 +179,7 @@ const Examine = () => {
// 获取内容 // 获取内容
const getChapterVal = async () => { const getChapterVal = async () => {
const requestId = ++chapterRequestRef.current const requestId = ++chapterRequestRef.current
setChapterLoadFailed(false)
setLoading(true) setLoading(true)
try { try {
const data = await getInfoByChapterId({ chapter_id: chapterId }) const data = await getInfoByChapterId({ chapter_id: chapterId })
...@@ -193,8 +196,10 @@ const Examine = () => { ...@@ -193,8 +196,10 @@ const Examine = () => {
contentMd5Ref.current = md5(content) contentMd5Ref.current = md5(content)
setHtml(content) setHtml(content)
setContentId(cId) setContentId(cId)
setLoadedChapterId(chapterId)
} catch { } catch {
// 请求层已统一展示错误提示。 // 请求层已统一展示错误提示。
if (requestId === chapterRequestRef.current) setChapterLoadFailed(true)
} finally { } finally {
if (requestId === chapterRequestRef.current) setLoading(false) if (requestId === chapterRequestRef.current) setLoading(false)
} }
...@@ -236,7 +241,6 @@ const Examine = () => { ...@@ -236,7 +241,6 @@ const Examine = () => {
useEffect(() => { useEffect(() => {
if (chapterId) { if (chapterId) {
setContentId(false) setContentId(false)
setHtml('')
void getChapterVal() void getChapterVal()
} }
}, [chapterId]) }, [chapterId])
...@@ -262,6 +266,10 @@ const Examine = () => { ...@@ -262,6 +266,10 @@ const Examine = () => {
setAutosaveTime(Date.now()) setAutosaveTime(Date.now())
} }
const editorReady = Boolean(
chapterId && contentId && Number(loadedChapterId) === Number(chapterId),
)
const addChapterParent = () => { const addChapterParent = () => {
setEditKey(-1) setEditKey(-1)
setEditValue('') setEditValue('')
...@@ -530,7 +538,9 @@ const Examine = () => { ...@@ -530,7 +538,9 @@ const Examine = () => {
<div className='book-content-editor' style={{ flex: 1, minWidth: 0 }}> <div className='book-content-editor' style={{ flex: 1, minWidth: 0 }}>
<div className='editor-right'> <div className='editor-right'>
<Spin spinning={loading}> <Spin spinning={loading}>
{editorReady && (
<WangEditorCustomer <WangEditorCustomer
key={chapterId}
ref={editorRef} ref={editorRef}
chapterId={chapterId} chapterId={chapterId}
bookId={id} bookId={id}
...@@ -553,6 +563,13 @@ const Examine = () => { ...@@ -553,6 +563,13 @@ const Examine = () => {
/> />
} }
/> />
)}
{!loading && chapterLoadFailed && (
<div className='chapter-load-error'>
<p>章节内容加载失败</p>
<Button onClick={() => void getChapterVal()}>重新加载</Button>
</div>
)}
</Spin> </Spin>
</div> </div>
</div> </div>
......
...@@ -83,6 +83,15 @@ ...@@ -83,6 +83,15 @@
height: 100%; height: 100%;
} }
} }
.chapter-load-error {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #6f7886;
}
} }
.draggable-tree { .draggable-tree {
flex: 1; flex: 1;
......
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ComponentProps, ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Examine from './index'
const mocks = vi.hoisted(() => ({
getAllList: vi.fn(),
getInfoByChapterId: vi.fn(),
sectionEdit: vi.fn(),
setAutosaveTime: vi.fn(),
setTreeChapter: vi.fn(),
}))
vi.mock('antd', () => {
const PassThrough = ({ children }: { children?: ReactNode }) => <>{children}</>
return {
App: { useApp: () => ({ modal: { confirm: vi.fn() } }) },
Button: ({ children, onClick, ...props }: ComponentProps<'button'>) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
Col: PassThrough,
Descriptions: ({ items = [] }: { items?: Array<{ key: string; label?: ReactNode }> }) => (
<>
{items.map((item) => (
<div key={item.key}>{item.label}</div>
))}
</>
),
Dropdown: PassThrough,
Modal: ({ children, open }: { children?: ReactNode; open?: boolean }) =>
open ? <>{children}</> : null,
Row: PassThrough,
Spin: PassThrough,
Tooltip: PassThrough,
Tree: ({
disabled,
onSelect,
treeData = [],
}: {
disabled?: boolean
onSelect: (keys: number[], info: { node: { key: number; title: string } }) => void
treeData?: Array<{ key: number; title: string }>
}) => (
<div>
{treeData.map((node) => (
<button key={node.key} disabled={disabled} onClick={() => onSelect([node.key], { node })}>
{node.title}
</button>
))}
</div>
),
}
})
vi.mock('@ant-design/icons', () => ({
DiffOutlined: () => null,
EllipsisOutlined: () => null,
MenuFoldOutlined: () => null,
MenuUnfoldOutlined: () => null,
}))
vi.mock('@/components/editor/wangeditor-customer', () => ({
default: ({ chapterId, html }: { chapterId: number; html: string }) => (
<div data-chapter-id={chapterId} data-html={html} data-testid='chapter-editor' />
),
}))
vi.mock('../../review-comments/views/EditorReviewComments', () => ({ default: () => null }))
vi.mock('./components/form-chapter-title', () => ({ default: () => null }))
vi.mock('./components/EditChapterEditors', () => ({ default: () => null }))
vi.mock('./components/BindKnowledgeGraph', () => ({ default: () => null }))
vi.mock('@/stores/editorStore', () => ({
useEditorStore: (
selector: (state: { setAutosaveTime: typeof mocks.setAutosaveTime }) => unknown,
) => selector({ setAutosaveTime: mocks.setAutosaveTime }),
}))
vi.mock('@/stores/userStore', () => ({
useUserStore: (
selector: (state: {
treeChapter: null
userInfo: { id: number; type: number }
setTreeChapter: typeof mocks.setTreeChapter
}) => unknown,
) =>
selector({
treeChapter: null,
userInfo: { id: 1, type: 1 },
setTreeChapter: mocks.setTreeChapter,
}),
}))
vi.mock('react-router', () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams('book_id=10')],
}))
vi.mock('@/utils/common', () => ({
convertToAntdTreeData: (data: Array<{ id: number; name: string }>) =>
data.map(({ id, name }) => ({ key: id, title: name })),
findFirstNotHasChildren: (data: Array<{ key: number; title: string }>) => data[0],
findNodeById: () => [],
findParentLevelOne: () => null,
findTreeElementByKey: (
data: Array<{ key: number; title: string }>,
_field: string,
value?: number,
) => data.find((item) => item.key === Number(value)),
findTreeToIndex: () => 0,
}))
vi.mock('./api', () => ({
chapterDel: vi.fn(),
dragOrder: vi.fn(),
getAllList: mocks.getAllList,
getInfoByChapterId: mocks.getInfoByChapterId,
sectionEdit: mocks.sectionEdit,
}))
vi.mock('../api', () => ({ getBook: vi.fn() }))
const deferred = <T,>() => {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
describe('chapter editor lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getAllList.mockResolvedValue({
code: 200,
data: [
{ id: 1, name: '章节一' },
{ id: 2, name: '章节二' },
],
})
})
it('creates a fresh editor only after the selected chapter loads successfully', async () => {
const secondChapterRequest = deferred<{
id: number
content: string
selected_editor_ids: number[]
}>()
mocks.getInfoByChapterId
.mockResolvedValueOnce({
id: 101,
content: '<p>章节一内容</p>',
selected_editor_ids: [1],
})
.mockReturnValueOnce(secondChapterRequest.promise)
.mockResolvedValueOnce({
id: 102,
content: '<p>章节二内容</p>',
selected_editor_ids: [1],
})
render(<Examine />)
const firstEditor = await screen.findByTestId('chapter-editor')
expect(firstEditor).toHaveAttribute('data-chapter-id', '1')
fireEvent.click(screen.getByRole('button', { name: '章节二' }))
await waitFor(() => expect(screen.queryByTestId('chapter-editor')).not.toBeInTheDocument())
await act(async () => {
secondChapterRequest.reject(new Error('load failed'))
await secondChapterRequest.promise.catch(() => undefined)
})
expect(await screen.findByText('章节内容加载失败')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重新加载' }))
const secondEditor = await screen.findByTestId('chapter-editor')
expect(secondEditor).not.toBe(firstEditor)
expect(secondEditor).toHaveAttribute('data-chapter-id', '2')
expect(secondEditor).toHaveAttribute('data-html', '<p>章节二内容</p>')
})
})
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论