feat: 添加评论、反应、离线编辑及主题定制功能
All checks were successful
test/timeline-frontend/pipeline/head This commit looks good

- 实现评论系统,包括评论输入、列表展示和集成指南
- 添加反应功能组件(ReactionBar、ReactionButton、ReactionPicker)
- 实现离线编辑支持,包括同步状态管理和冲突解决
- 添加主题定制功能,支持多种配色方案和主题预览
- 新增多视图布局选项(时间线、分组、砌体视图)
- 实现个人资料编辑器,支持头像、简介和自定义字段编辑
- 添加统计页面,展示存储使用情况和上传趋势
- 新增相册管理功能,支持相册创建、编辑和照片管理
- 实现响应式设计和加载骨架屏组件
- 扩展国际化支持,新增孟加拉语、波斯语、印尼语、日语、葡萄牙语等语言
- 添加错误边界组件和离线指示器
- 更新配置文件、路由和依赖项
- 新增完整的文档、测试用例和集成指南
This commit is contained in:
2026-02-25 15:02:05 +08:00
parent 97a5ad3a00
commit 5a0aa2b3c1
210 changed files with 23556 additions and 300 deletions

View 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
};

View File

@@ -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>
);
};

View File

@@ -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>
</>

View File

@@ -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();
});
});