feat: add DocumentEditor with dirty tracking and save

This commit is contained in:
Tianyang 2026-08-01 02:47:29 +08:00
parent 020ddac994
commit e341a56fb2
2 changed files with 143 additions and 0 deletions

View File

@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import Editor from '@monaco-editor/react';
import { getDocument, saveDocument } from '../api/client';
interface DocumentEditorProps {
documentKey: string;
}
export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
const [content, setContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [contentType, setContentType] = useState('text/markdown');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
getDocument(documentKey)
.then((doc) => {
setContent(doc.content);
setOriginalContent(doc.content);
setContentType(doc.contentType);
setLoading(false);
})
.catch(() => {
setError('无法加载文档');
setLoading(false);
});
}, [documentKey]);
const isDirty = content !== originalContent;
function handleChange(value: string | undefined) {
setContent(value ?? '');
}
function handleSave() {
setSaving(true);
saveDocument(documentKey, content, contentType)
.then(() => {
setOriginalContent(content);
setSaving(false);
})
.catch(() => {
setError('保存失败');
setSaving(false);
});
}
if (loading) {
return <div data-testid="document-editor-loading"></div>;
}
if (error) {
return <div data-testid="document-editor-error">{error}</div>;
}
return (
<div data-testid="document-editor">
<div className="doc-header">
<span data-testid="save-status">{isDirty ? '未保存' : '已归档'}</span>
<button onClick={handleSave} disabled={!isDirty || saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
</div>
);
}

View File

@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest';
import DocumentEditor from '../../src/components/DocumentEditor';
import { getDocument, saveDocument } from '../../src/api/client';
vi.mock('../../src/api/client');
vi.mock('@monaco-editor/react', () => ({
default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => (
<textarea
data-testid="monaco-editor-mock"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
}));
beforeEach(() => {
vi.mocked(getDocument).mockReset();
vi.mocked(saveDocument).mockReset();
});
test('shows a loading state, then loads and displays document content', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
});
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
const textarea = await screen.findByTestId('monaco-editor-mock');
expect(textarea).toHaveValue('# 架构设计');
expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
});
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
test('typing marks the document dirty and saving clears the dirty state', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
});
vi.mocked(saveDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
updatedAt: '2026-08-01T10:05:00Z',
});
const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
const textarea = await screen.findByTestId('monaco-editor-mock');
await user.clear(textarea);
await user.type(textarea, '# 新内容');
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
const saveButton = screen.getByRole('button', { name: '保存' });
await user.click(saveButton);
await waitFor(() => {
expect(saveDocument).toHaveBeenCalledWith('产品文档/架构设计.md', '# 新内容', 'text/markdown');
});
await waitFor(() => {
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
});
});