feat: 添加评论、反应、离线编辑及主题定制功能
All checks were successful
test/timeline-frontend/pipeline/head This commit looks good
All checks were successful
test/timeline-frontend/pipeline/head This commit looks good
- 实现评论系统,包括评论输入、列表展示和集成指南 - 添加反应功能组件(ReactionBar、ReactionButton、ReactionPicker) - 实现离线编辑支持,包括同步状态管理和冲突解决 - 添加主题定制功能,支持多种配色方案和主题预览 - 新增多视图布局选项(时间线、分组、砌体视图) - 实现个人资料编辑器,支持头像、简介和自定义字段编辑 - 添加统计页面,展示存储使用情况和上传趋势 - 新增相册管理功能,支持相册创建、编辑和照片管理 - 实现响应式设计和加载骨架屏组件 - 扩展国际化支持,新增孟加拉语、波斯语、印尼语、日语、葡萄牙语等语言 - 添加错误边界组件和离线指示器 - 更新配置文件、路由和依赖项 - 新增完整的文档、测试用例和集成指南
This commit is contained in:
93
src/pages/story/components/OfflineStoryEditor.tsx
Normal file
93
src/pages/story/components/OfflineStoryEditor.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/pages/story/components/OfflineStoryEditor.tsx
|
||||
import React from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { useOfflineStoryService } from '../offlineService';
|
||||
import type { StoryType, StoryItem } from '../data.d';
|
||||
|
||||
/**
|
||||
* Wrapper component that provides offline-aware story editing capabilities
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { addStory, updateStory, addStoryItem, updateStoryItem, removeStoryItem } = useOfflineStoryEditor();
|
||||
*
|
||||
* // These functions automatically handle online/offline scenarios
|
||||
* await addStory({ title: 'My Story', description: 'Description' });
|
||||
* await addStoryItem(formData);
|
||||
* ```
|
||||
*/
|
||||
export function useOfflineStoryEditor() {
|
||||
const { syncState } = useModel('sync');
|
||||
const offlineService = useOfflineStoryService();
|
||||
|
||||
return {
|
||||
...offlineService,
|
||||
isOnline: syncState.isOnline,
|
||||
isSyncing: syncState.isSyncing,
|
||||
pendingChanges: syncState.pendingChanges,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HOC to wrap story components with offline editing support
|
||||
*/
|
||||
export function withOfflineSupport<P extends object>(
|
||||
Component: React.ComponentType<P>
|
||||
): React.FC<P> {
|
||||
return (props: P) => {
|
||||
const { syncState } = useModel('sync');
|
||||
|
||||
// Show offline indicator if offline
|
||||
React.useEffect(() => {
|
||||
if (!syncState.isOnline && syncState.pendingChanges > 0) {
|
||||
message.info({
|
||||
content: `离线模式: ${syncState.pendingChanges} 个更改待同步`,
|
||||
duration: 3,
|
||||
key: 'offline-indicator',
|
||||
});
|
||||
}
|
||||
}, [syncState.isOnline, syncState.pendingChanges]);
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Example integration component showing how to use offline editing
|
||||
*/
|
||||
export const OfflineStoryEditorExample: React.FC = () => {
|
||||
const { addStory, updateStory, isOnline, pendingChanges } = useOfflineStoryEditor();
|
||||
|
||||
const handleCreateStory = async () => {
|
||||
try {
|
||||
const story = await addStory({
|
||||
title: '新故事',
|
||||
description: '这是一个测试故事',
|
||||
});
|
||||
|
||||
message.success(isOnline ? '故事创建成功' : '故事已保存到本地');
|
||||
return story;
|
||||
} catch (error) {
|
||||
message.error('创建故事失败');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStory = async (instanceId: string, updates: Partial<StoryType>) => {
|
||||
try {
|
||||
const story = await updateStory({
|
||||
instanceId,
|
||||
...updates,
|
||||
});
|
||||
|
||||
message.success(isOnline ? '故事更新成功' : '更改已保存到本地');
|
||||
return story;
|
||||
} catch (error) {
|
||||
message.error('更新故事失败');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return null; // This is just an example component
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
// TimelineGridItem.tsx - Grid card layout for timeline items
|
||||
import TimelineImage from '@/components/TimelineImage';
|
||||
import { ReactionBar } from '@/components/Reactions';
|
||||
import useReactions from '@/hooks/useReactions';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
import { batchGetFileInfo, queryStoryItemImages } from '@/services/file/api';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
@@ -40,6 +42,18 @@ const TimelineGridItem: React.FC<{
|
||||
const intl = useIntl();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// Initialize reactions for this story item
|
||||
const {
|
||||
reactions,
|
||||
addReaction,
|
||||
updateReaction,
|
||||
removeReaction,
|
||||
actionLoading: reactionLoading,
|
||||
} = useReactions('story', item.instanceId || '', {
|
||||
autoFetch: true,
|
||||
autoSubscribe: true,
|
||||
});
|
||||
|
||||
// 动态设置CSS变量以适配主题
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -233,6 +247,25 @@ const TimelineGridItem: React.FC<{
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reactions */}
|
||||
<div
|
||||
className="item-reactions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ marginTop: '8px' }}
|
||||
>
|
||||
<ReactionBar
|
||||
entityType="story"
|
||||
entityId={item.instanceId || ''}
|
||||
reactionSummary={reactions || undefined}
|
||||
onAdd={addReaction}
|
||||
onRemove={removeReaction}
|
||||
onChange={updateReaction}
|
||||
loading={reactionLoading}
|
||||
size="small"
|
||||
showPicker={false}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// src/pages/story/components/TimelineItemDrawer.tsx
|
||||
import TimelineImage from '@/components/TimelineImage';
|
||||
import { Comments } from '@/components/Comments';
|
||||
import { ReactionBar } from '@/components/Reactions';
|
||||
import useComments from '@/hooks/useComments';
|
||||
import useReactions from '@/hooks/useReactions';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
import { queryStoryItemImages } from '@/pages/story/service';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
@@ -44,6 +48,36 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
},
|
||||
);
|
||||
|
||||
// Initialize comments for this story item
|
||||
const {
|
||||
comments,
|
||||
loading: commentsLoading,
|
||||
createLoading,
|
||||
updateLoading,
|
||||
deleteLoading,
|
||||
addComment,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
} = useComments({
|
||||
entityType: 'story',
|
||||
entityId: storyItem.instanceId || '',
|
||||
autoFetch: open,
|
||||
autoSubscribe: open,
|
||||
});
|
||||
|
||||
// Initialize reactions for this story item
|
||||
const {
|
||||
reactions,
|
||||
userReaction,
|
||||
addReaction,
|
||||
updateReaction,
|
||||
removeReaction,
|
||||
actionLoading: reactionLoading,
|
||||
} = useReactions('story', storyItem.instanceId || '', {
|
||||
autoFetch: open,
|
||||
autoSubscribe: open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
run(storyItem.instanceId);
|
||||
@@ -209,6 +243,43 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider style={{ borderColor: token.colorBorder }} />
|
||||
|
||||
{/* Reactions Section */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ color: token.colorText }}>互动</h3>
|
||||
<ReactionBar
|
||||
entityType="story"
|
||||
entityId={storyItem.instanceId || ''}
|
||||
reactionSummary={reactions || undefined}
|
||||
onAdd={addReaction}
|
||||
onRemove={removeReaction}
|
||||
onChange={updateReaction}
|
||||
loading={reactionLoading}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider style={{ borderColor: token.colorBorder }} />
|
||||
|
||||
{/* Comments Section */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Comments
|
||||
entityType="story"
|
||||
entityId={storyItem.instanceId || ''}
|
||||
comments={comments}
|
||||
loading={commentsLoading}
|
||||
onCreate={addComment}
|
||||
onEdit={updateComment}
|
||||
onDelete={deleteComment}
|
||||
createLoading={createLoading}
|
||||
editLoading={updateLoading}
|
||||
deleteLoading={deleteLoading}
|
||||
showCard={false}
|
||||
title="评论"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* TimelineItemDrawer Integration Test
|
||||
* Feature: personal-user-enhancements
|
||||
* Tests: Comments integration in story detail view
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import TimelineItemDrawer from '../TimelineItemDrawer';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@umijs/max', () => ({
|
||||
useIntl: () => ({
|
||||
formatMessage: ({ id }: { id: string }) => id,
|
||||
}),
|
||||
useRequest: () => ({
|
||||
data: [],
|
||||
run: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
return {
|
||||
...actual,
|
||||
theme: {
|
||||
useToken: () => ({
|
||||
token: {
|
||||
colorBgContainer: '#fff',
|
||||
colorText: '#000',
|
||||
colorTextSecondary: '#666',
|
||||
colorBorder: '#d9d9d9',
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@/hooks/useComments', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
comments: [],
|
||||
loading: false,
|
||||
createLoading: false,
|
||||
updateLoading: false,
|
||||
deleteLoading: false,
|
||||
addComment: jest.fn(),
|
||||
updateComment: jest.fn(),
|
||||
deleteComment: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/TimelineImage', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Mock Image</div>,
|
||||
}));
|
||||
|
||||
describe('TimelineItemDrawer - Comments Integration', () => {
|
||||
const mockStoryItem: StoryItem = {
|
||||
instanceId: 'story-123',
|
||||
title: 'Test Story',
|
||||
description: 'Test Description',
|
||||
storyItemTime: [2024, 1, 15, 10, 30, 0],
|
||||
location: 'Test Location',
|
||||
createTime: [2024, 1, 15, 10, 30, 0],
|
||||
updateTime: [2024, 1, 15, 10, 30, 0],
|
||||
createName: 'Test User',
|
||||
updateName: 'Test User',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
storyItem: mockStoryItem,
|
||||
open: true,
|
||||
setOpen: jest.fn(),
|
||||
handleDelete: jest.fn(),
|
||||
handOption: jest.fn(),
|
||||
disableEdit: false,
|
||||
};
|
||||
|
||||
it('should render Comments component when drawer is open', async () => {
|
||||
render(<TimelineItemDrawer {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check if Comments section is rendered
|
||||
expect(screen.getByText('评论')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct entityType and entityId to Comments', async () => {
|
||||
const { container } = render(<TimelineItemDrawer {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify the drawer is rendered
|
||||
expect(container.querySelector('.ant-drawer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render Comments when drawer is closed', () => {
|
||||
render(<TimelineItemDrawer {...defaultProps} open={false} />);
|
||||
|
||||
// Comments should not be visible when drawer is closed
|
||||
expect(screen.queryByText('评论')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user