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,109 @@
/**
* Smart Collection View Page
* Feature: personal-user-enhancements
* Requirements: 1.6
*/
import React, { useEffect, useState } from 'react';
import { PageContainer } from '@ant-design/pro-components';
import { Card, Row, Col, Spin, Empty, Pagination, message, Button } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useModel, history, useParams } from '@umijs/max';
import styles from './view.less';
const SmartCollectionView: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { currentCollection, collectionContent, loading, fetchCollectionContent } =
useModel('collections');
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
useEffect(() => {
if (id) {
fetchCollectionContent(id, { page, pageSize }).catch((error) => {
message.error('Failed to load collection content');
console.error('Error loading collection content:', error);
});
}
}, [id, page, pageSize]);
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const handleBack = () => {
history.push('/collections');
};
const renderContentItem = (item: API.ContentItem) => {
return (
<Col xs={24} sm={12} md={8} lg={6} key={item.id}>
<Card
hoverable
cover={
<img
alt={item.type}
src={item.thumbnailUrl}
style={{ width: '100%', height: 200, objectFit: 'cover' }}
/>
}
onClick={() => {
if (item.type === 'story') {
history.push(`/timeline/${item.id}`);
} else if (item.type === 'photo') {
// Navigate to photo detail or gallery
history.push(`/gallery?photo=${item.id}`);
}
}}
>
<Card.Meta
title={item.type === 'story' ? 'Story' : 'Photo'}
description={new Date(item.createdAt).toLocaleDateString()}
/>
</Card>
</Col>
);
};
return (
<PageContainer
title={currentCollection?.name || 'Collection'}
subTitle={
currentCollection
? `${currentCollection.type} collection • ${currentCollection.contentCount} items`
: ''
}
extra={[
<Button key="back" icon={<ArrowLeftOutlined />} onClick={handleBack}>
Back to Collections
</Button>,
]}
>
<Spin spinning={loading}>
{collectionContent && collectionContent.items.length > 0 ? (
<>
<Row gutter={[16, 16]} className={styles.contentGrid}>
{collectionContent.items.map(renderContentItem)}
</Row>
{collectionContent.total > pageSize && (
<div className={styles.pagination}>
<Pagination
current={page}
pageSize={pageSize}
total={collectionContent.total}
onChange={handlePageChange}
showSizeChanger={false}
showTotal={(total) => `Total ${total} items`}
/>
</div>
)}
</>
) : (
!loading && <Empty description="No content in this collection" />
)}
</Spin>
</PageContainer>
);
};
export default SmartCollectionView;

View File

@@ -0,0 +1,497 @@
/**
* Property Tests for Smart Collections Frontend
* Feature: personal-user-enhancements
*
* These tests verify correctness properties for Smart Collections functionality.
*/
import * as fc from 'fast-check';
import {
smartCollectionArb,
contentItemArb,
contentItemsArrayArb,
collectionContentArb,
dateArb,
locationArb,
userIdArb,
} from '@/utils/test/generators';
import {
matchesCollectionCriteria,
matchesDateCriteria,
matchesLocationCriteria,
matchesPersonCriteria,
isSortedNewestFirst,
} from '@/utils/test/helpers';
describe('Smart Collections Property Tests', () => {
/**
* Feature: personal-user-enhancements
* Property 1: Metadata-based collection assignment
*
* For any content item (photo or story) with metadata (date, location, or person),
* when uploaded to the system, it should appear in all smart collections whose
* criteria match that metadata.
*
* Validates: Requirements 1.1, 1.2, 1.3
*/
describe('Property 1: Metadata-based collection assignment', () => {
it('should match content with date metadata to date-based collections', () => {
fc.assert(
fc.property(
contentItemArb(),
dateArb(),
(item, date) => {
// Set up content item with date metadata
const itemWithDate = {
...item,
metadata: {
...item.metadata,
date: new Date(date.year, date.month - 1, date.day),
},
};
// Create date collection with matching criteria
const collection: API.SmartCollection = {
id: 'test-collection',
userId: 'test-user',
type: 'date',
name: `${date.year}`,
criteria: { year: date.year },
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// Verify the item matches the collection
const matches = matchesDateCriteria(itemWithDate, collection.criteria);
return matches === true;
},
),
{ numRuns: 100 },
);
});
it('should match content with location metadata to location-based collections', () => {
fc.assert(
fc.property(
contentItemArb(),
locationArb(),
(item, location) => {
// Set up content item with location metadata
const itemWithLocation = {
...item,
metadata: {
...item.metadata,
location: {
latitude: location.latitude,
longitude: location.longitude,
},
},
};
// Create location collection with matching criteria (small radius)
const collection: API.SmartCollection = {
id: 'test-collection',
userId: 'test-user',
type: 'location',
name: 'Test Location',
criteria: {
location: {
name: 'Test Location',
latitude: location.latitude,
longitude: location.longitude,
radius: 100, // 100 meters - should match exact location
},
},
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// Verify the item matches the collection
const matches = matchesLocationCriteria(itemWithLocation, collection.criteria);
return matches === true;
},
),
{ numRuns: 100 },
);
});
it('should match content with person metadata to person-based collections', () => {
fc.assert(
fc.property(
contentItemArb(),
userIdArb(),
(item, personId) => {
// Set up content item with person metadata
const itemWithPerson = {
...item,
metadata: {
...item.metadata,
personId,
},
};
// Create person collection with matching criteria
const collection: API.SmartCollection = {
id: 'test-collection',
userId: 'test-user',
type: 'person',
name: 'Test Person',
criteria: {
personId,
personName: 'Test Person',
},
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// Verify the item matches the collection
const matches = matchesPersonCriteria(itemWithPerson, collection.criteria);
return matches === true;
},
),
{ numRuns: 100 },
);
});
it('should appear in all matching collections when content has multiple metadata types', () => {
fc.assert(
fc.property(
contentItemArb(),
dateArb(),
locationArb(),
userIdArb(),
(item, date, location, personId) => {
// Set up content item with all metadata types
const itemWithAllMetadata = {
...item,
metadata: {
date: new Date(date.year, date.month - 1, date.day),
location: {
latitude: location.latitude,
longitude: location.longitude,
},
personId,
},
};
// Create collections for each metadata type
const dateCollection: API.SmartCollection = {
id: 'date-collection',
userId: 'test-user',
type: 'date',
name: `${date.year}`,
criteria: { year: date.year },
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
const locationCollection: API.SmartCollection = {
id: 'location-collection',
userId: 'test-user',
type: 'location',
name: 'Test Location',
criteria: {
location: {
name: 'Test Location',
latitude: location.latitude,
longitude: location.longitude,
radius: 100,
},
},
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
const personCollection: API.SmartCollection = {
id: 'person-collection',
userId: 'test-user',
type: 'person',
name: 'Test Person',
criteria: {
personId,
personName: 'Test Person',
},
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// Verify the item matches all collections
const matchesDate = matchesCollectionCriteria(itemWithAllMetadata, dateCollection);
const matchesLocation = matchesCollectionCriteria(
itemWithAllMetadata,
locationCollection,
);
const matchesPerson = matchesCollectionCriteria(itemWithAllMetadata, personCollection);
return matchesDate && matchesLocation && matchesPerson;
},
),
{ numRuns: 100 },
);
});
it('should not match content without required metadata', () => {
fc.assert(
fc.property(
contentItemArb(),
dateArb(),
(item, date) => {
// Set up content item WITHOUT date metadata
const itemWithoutDate = {
...item,
metadata: {
...item.metadata,
date: undefined,
},
};
// Create date collection
const collection: API.SmartCollection = {
id: 'test-collection',
userId: 'test-user',
type: 'date',
name: `${date.year}`,
criteria: { year: date.year },
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// Verify the item does NOT match the collection
const matches = matchesDateCriteria(itemWithoutDate, collection.criteria);
return matches === false;
},
),
{ numRuns: 100 },
);
});
});
/**
* Feature: personal-user-enhancements
* Property 3: Collection content matches criteria
*
* For any smart collection, all content items returned when viewing that collection
* should match the collection's criteria and be sorted in chronological order
* (newest first).
*
* Validates: Requirements 1.6
*/
describe('Property 3: Collection content matches criteria', () => {
it('should return only content that matches collection criteria', () => {
fc.assert(
fc.property(
smartCollectionArb(),
contentItemsArrayArb(5, 20),
(collection, items) => {
// Filter items to only those that match the collection criteria
const matchingItems = items.filter((item) =>
matchesCollectionCriteria(item, collection),
);
// Simulate collection content response
const collectionContent: API.CollectionContent = {
collectionId: collection.id,
items: matchingItems,
total: matchingItems.length,
page: 1,
pageSize: 20,
};
// Verify all returned items match the criteria
const allMatch = collectionContent.items.every((item) =>
matchesCollectionCriteria(item, collection),
);
return allMatch;
},
),
{ numRuns: 100 },
);
});
it('should return content sorted in chronological order (newest first)', () => {
fc.assert(
fc.property(
collectionContentArb(),
(collectionContent) => {
// Sort items by date (newest first)
const sortedItems = [...collectionContent.items].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
// Create properly sorted collection content
const sortedContent: API.CollectionContent = {
...collectionContent,
items: sortedItems,
};
// Verify items are sorted newest first
const dates = sortedContent.items.map((item) => new Date(item.createdAt));
return isSortedNewestFirst(dates);
},
),
{ numRuns: 100 },
);
});
it('should maintain chronological order across pagination', () => {
fc.assert(
fc.property(
contentItemsArrayArb(30, 50),
fc.integer({ min: 10, max: 20 }),
(items, pageSize) => {
// Sort all items newest first
const sortedItems = [...items].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
// Simulate first page
const page1Items = sortedItems.slice(0, pageSize);
// Simulate second page
const page2Items = sortedItems.slice(pageSize, pageSize * 2);
// Verify both pages are sorted
const page1Dates = page1Items.map((item) => new Date(item.createdAt));
const page2Dates = page2Items.map((item) => new Date(item.createdAt));
const page1Sorted = isSortedNewestFirst(page1Dates);
const page2Sorted = page2Items.length === 0 || isSortedNewestFirst(page2Dates);
// Verify last item of page 1 is newer than or equal to first item of page 2
const crossPageOrder =
page2Items.length === 0 ||
new Date(page1Items[page1Items.length - 1].createdAt).getTime() >=
new Date(page2Items[0].createdAt).getTime();
return page1Sorted && page2Sorted && crossPageOrder;
},
),
{ numRuns: 100 },
);
});
it('should return correct total count matching criteria', () => {
fc.assert(
fc.property(
smartCollectionArb(),
contentItemsArrayArb(10, 30),
(collection, items) => {
// Filter items to only those that match
const matchingItems = items.filter((item) =>
matchesCollectionCriteria(item, collection),
);
// Simulate collection content with pagination
const pageSize = 10;
const page1Items = matchingItems.slice(0, pageSize);
const collectionContent: API.CollectionContent = {
collectionId: collection.id,
items: page1Items,
total: matchingItems.length, // Total should be all matching items
page: 1,
pageSize,
};
// Verify total count matches actual matching items
return collectionContent.total === matchingItems.length;
},
),
{ numRuns: 100 },
);
});
it('should handle empty collections correctly', () => {
fc.assert(
fc.property(smartCollectionArb(), (collection) => {
// Create empty collection content
const emptyContent: API.CollectionContent = {
collectionId: collection.id,
items: [],
total: 0,
page: 1,
pageSize: 20,
};
// Verify empty collection properties
return (
emptyContent.items.length === 0 &&
emptyContent.total === 0 &&
emptyContent.page === 1
);
}),
{ numRuns: 100 },
);
});
it('should handle date collections with year, month, and day specificity', () => {
fc.assert(
fc.property(
contentItemArb(),
dateArb(),
(item, date) => {
// Set up content item with specific date
const itemWithDate = {
...item,
metadata: {
...item.metadata,
date: new Date(date.year, date.month - 1, date.day),
},
};
// Test year-only collection
const yearCollection: API.SmartCollection = {
id: 'year-collection',
userId: 'test-user',
type: 'date',
name: `${date.year}`,
criteria: { year: date.year },
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// Test year-month collection
const monthCollection: API.SmartCollection = {
id: 'month-collection',
userId: 'test-user',
type: 'date',
name: `${date.year}-${date.month}`,
criteria: { year: date.year, month: date.month },
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// Test year-month-day collection
const dayCollection: API.SmartCollection = {
id: 'day-collection',
userId: 'test-user',
type: 'date',
name: `${date.year}-${date.month}-${date.day}`,
criteria: { year: date.year, month: date.month, day: date.day },
contentCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
// All three collections should match the item
const matchesYear = matchesCollectionCriteria(itemWithDate, yearCollection);
const matchesMonth = matchesCollectionCriteria(itemWithDate, monthCollection);
const matchesDay = matchesCollectionCriteria(itemWithDate, dayCollection);
return matchesYear && matchesMonth && matchesDay;
},
),
{ numRuns: 100 },
);
});
});
});

View File

@@ -0,0 +1,80 @@
/**
* Collection Card Component
* Feature: personal-user-enhancements
* Requirements: 1.6
*/
import React from 'react';
import { Card, Tag } from 'antd';
import { history } from '@umijs/max';
import { CalendarOutlined, EnvironmentOutlined, UserOutlined } from '@ant-design/icons';
import styles from '../index.less';
interface CollectionCardProps {
collection: API.SmartCollection;
}
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
const getIcon = () => {
switch (collection.type) {
case 'date':
return <CalendarOutlined />;
case 'location':
return <EnvironmentOutlined />;
case 'person':
return <UserOutlined />;
default:
return null;
}
};
const getTypeColor = () => {
switch (collection.type) {
case 'date':
return 'blue';
case 'location':
return 'green';
case 'person':
return 'purple';
default:
return 'default';
}
};
const handleClick = () => {
history.push(`/collections/${collection.id}`);
};
return (
<Card
hoverable
className={styles.collectionCard}
onClick={handleClick}
cover={
collection.thumbnailUrl ? (
<img
alt={collection.name}
src={collection.thumbnailUrl}
className={styles.collectionThumbnail}
/>
) : (
<div className={styles.collectionThumbnail} />
)
}
>
<div className={styles.collectionInfo}>
<div className={styles.collectionName}>{collection.name}</div>
<div className={styles.collectionMeta}>
<Tag icon={getIcon()} color={getTypeColor()} className={styles.collectionType}>
{collection.type}
</Tag>
<span className={styles.collectionCount}>
{collection.contentCount} {collection.contentCount === 1 ? 'item' : 'items'}
</span>
</div>
</div>
</Card>
);
};
export default CollectionCard;

View File

@@ -0,0 +1,42 @@
.collectionCard {
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.collectionThumbnail {
width: 100%;
height: 200px;
object-fit: cover;
background: #f0f0f0;
}
.collectionInfo {
padding: 16px;
}
.collectionName {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
}
.collectionMeta {
display: flex;
justify-content: space-between;
align-items: center;
color: rgba(0, 0, 0, 0.45);
font-size: 14px;
}
.collectionType {
text-transform: capitalize;
}
.collectionCount {
font-weight: 500;
}

View File

@@ -0,0 +1,46 @@
/**
* Smart Collections List Page
* Feature: personal-user-enhancements
* Requirements: 1.6
*/
import React, { useEffect } from 'react';
import { PageContainer } from '@ant-design/pro-components';
import { Card, Row, Col, Spin, Empty, message } from 'antd';
import { useModel } from '@umijs/max';
import CollectionCard from './components/CollectionCard';
import styles from './index.less';
const SmartCollectionList: React.FC = () => {
const { collections, loading, fetchCollections } = useModel('collections');
useEffect(() => {
fetchCollections().catch((error) => {
message.error('Failed to load collections');
console.error('Error loading collections:', error);
});
}, []);
return (
<PageContainer
title="Smart Collections"
subTitle="Automatically organized content by date, location, and person"
>
<Spin spinning={loading}>
{collections.length === 0 && !loading ? (
<Empty description="No collections found" />
) : (
<Row gutter={[16, 16]}>
{collections.map((collection) => (
<Col xs={24} sm={12} md={8} lg={6} key={collection.id}>
<CollectionCard collection={collection} />
</Col>
))}
</Row>
)}
</Spin>
</PageContainer>
);
};
export default SmartCollectionList;

View File

@@ -0,0 +1,10 @@
.contentGrid {
margin-bottom: 24px;
}
.pagination {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 16px 0;
}