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,43 @@
/**
* Theme Settings Component
* Feature: personal-user-enhancements
* Requirements: 7.1, 7.2, 7.3, 7.4
*/
import React from 'react';
import { Card, Space, Typography } from 'antd';
import { useIntl } from '@umijs/max';
import { ThemeSelector, ColorSchemePicker, ThemePreview } from '@/components/ThemeCustomizer';
const { Paragraph } = Typography;
const ThemeView: React.FC = () => {
const intl = useIntl();
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card title={intl.formatMessage({ id: 'theme.settings.mode.title' })} bordered={false}>
<Paragraph type="secondary">
{intl.formatMessage({ id: 'theme.settings.mode.description' })}
</Paragraph>
<ThemeSelector />
</Card>
<Card title={intl.formatMessage({ id: 'theme.settings.colorScheme.title' })} bordered={false}>
<Paragraph type="secondary">
{intl.formatMessage({ id: 'theme.settings.colorScheme.description' })}
</Paragraph>
<ColorSchemePicker />
</Card>
<Card title={intl.formatMessage({ id: 'theme.settings.preview.title' })} bordered={false}>
<Paragraph type="secondary">
{intl.formatMessage({ id: 'theme.settings.preview.description' })}
</Paragraph>
<ThemePreview />
</Card>
</Space>
);
};
export default ThemeView;

View File

@@ -5,8 +5,9 @@ import BaseView from './components/base';
import BindingView from './components/binding';
import NotificationView from './components/notification';
import SecurityView from './components/security';
import ThemeView from './components/theme';
import useStyles from './style.style';
type SettingsStateKeys = 'base' | 'security' | 'binding' | 'notification';
type SettingsStateKeys = 'base' | 'security' | 'binding' | 'notification' | 'theme';
type SettingsState = {
mode: 'inline' | 'horizontal';
selectKey: SettingsStateKeys;
@@ -18,6 +19,7 @@ const Settings: React.FC = () => {
security: '安全设置',
binding: '账号绑定',
notification: '新消息通知',
theme: '主题外观',
};
const [initConfig, setInitConfig] = useState<SettingsState>({
mode: 'inline',
@@ -66,6 +68,8 @@ const Settings: React.FC = () => {
return <BindingView />;
case 'notification':
return <NotificationView />;
case 'theme':
return <ThemeView />;
default:
return null;
}

View File

@@ -0,0 +1,31 @@
/**
* Statistics Overview Styles
* Feature: personal-user-enhancements
*/
.overview {
margin-bottom: 16px;
:global {
.ant-card {
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.ant-statistic-title {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 8px;
}
.ant-statistic-content {
font-size: 24px;
font-weight: 600;
}
}
}

View File

@@ -0,0 +1,64 @@
/**
* Statistics Overview Component
* Feature: personal-user-enhancements
* Requirements: 3.8
*/
import React from 'react';
import { Card, Row, Col, Statistic } from 'antd';
import { PictureOutlined, FileTextOutlined, DatabaseOutlined } from '@ant-design/icons';
import styles from './StatsOverview.less';
interface StatsOverviewProps {
stats: API.UserStatistics;
}
const StatsOverview: React.FC<StatsOverviewProps> = ({ stats }) => {
// Format storage bytes to human-readable format
const formatStorage = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
return (
<div className={styles.overview}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Total Photos"
value={stats.totalPhotos}
prefix={<PictureOutlined />}
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Total Stories"
value={stats.totalStories}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Storage Used"
value={formatStorage(stats.totalStorageBytes)}
prefix={<DatabaseOutlined />}
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
</Row>
</div>
);
};
export default StatsOverview;

View File

@@ -0,0 +1,89 @@
/**
* Storage Breakdown Pie Chart Component
* Feature: personal-user-enhancements
* Requirements: 3.8
*/
import React, { useMemo } from 'react';
import { Card } from 'antd';
import { Pie } from '@antv/plots';
interface StorageBreakdownProps {
storageBreakdown: API.StorageBreakdown;
}
const StorageBreakdown: React.FC<StorageBreakdownProps> = ({ storageBreakdown }) => {
// Format storage bytes to human-readable format
const formatStorage = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
// Transform data for the chart
const chartData = useMemo(() => {
return [
{ type: 'Photos', value: storageBreakdown.photos },
{ type: 'Videos', value: storageBreakdown.videos },
{ type: 'Documents', value: storageBreakdown.documents },
{ type: 'Other', value: storageBreakdown.other },
].filter((item) => item.value > 0); // Only show non-zero values
}, [storageBreakdown]);
const config = {
data: chartData,
angleField: 'value',
colorField: 'type',
radius: 0.8,
innerRadius: 0.6,
label: {
type: 'spider',
labelHeight: 28,
content: '{name}\n{percentage}',
},
legend: {
position: 'bottom' as const,
},
color: ['#3f8600', '#1890ff', '#faad14', '#cf1322'],
statistic: {
title: {
offsetY: -8,
content: 'Total',
},
content: {
offsetY: 4,
style: {
fontSize: '20px',
},
customHtml: () => {
const total = chartData.reduce((sum, item) => sum + item.value, 0);
return formatStorage(total);
},
},
},
tooltip: {
formatter: (datum: any) => {
return {
name: datum.type,
value: formatStorage(datum.value),
};
},
},
};
return (
<Card title="Storage Breakdown" style={{ marginTop: 16 }}>
{chartData.length > 0 ? (
<Pie {...config} />
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
No storage data available
</div>
)}
</Card>
);
};
export default StorageBreakdown;

View File

@@ -0,0 +1,77 @@
/**
* Upload Trends Chart Component
* Feature: personal-user-enhancements
* Requirements: 3.8
*/
import React, { useMemo } from 'react';
import { Card } from 'antd';
import { Column } from '@antv/plots';
interface UploadChartProps {
uploadTrends: API.UploadTrend[];
}
const UploadChart: React.FC<UploadChartProps> = ({ uploadTrends }) => {
// Transform data for the chart
const chartData = useMemo(() => {
const data: Array<{ period: string; count: number; type: string }> = [];
uploadTrends.forEach((trend) => {
data.push({
period: trend.period,
count: trend.photoCount,
type: 'Photos',
});
data.push({
period: trend.period,
count: trend.storyCount,
type: 'Stories',
});
});
return data;
}, [uploadTrends]);
const config = {
data: chartData,
xField: 'period',
yField: 'count',
seriesField: 'type',
isGroup: true,
columnStyle: {
radius: [4, 4, 0, 0],
},
label: {
position: 'top' as const,
style: {
fill: '#000000',
opacity: 0.6,
},
},
xAxis: {
label: {
autoRotate: true,
autoHide: true,
},
},
legend: {
position: 'top-right' as const,
},
color: ['#3f8600', '#1890ff'],
};
return (
<Card title="Upload Trends" style={{ marginTop: 16 }}>
{chartData.length > 0 ? (
<Column {...config} />
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
No upload data available
</div>
)}
</Card>
);
};
export default UploadChart;

View File

@@ -0,0 +1,17 @@
/**
* Statistics Dashboard Styles
* Feature: personal-user-enhancements
*/
.dashboard {
.charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 16px;
margin-top: 16px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
}

View File

@@ -0,0 +1,69 @@
/**
* Statistics Dashboard Page
* Feature: personal-user-enhancements
* Requirements: 3.8
*/
import React, { useEffect } from 'react';
import { PageContainer } from '@ant-design/pro-components';
import { Spin, message, Button } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { useModel } from '@umijs/max';
import StatsOverview from './components/StatsOverview';
import UploadChart from './components/UploadChart';
import StorageBreakdown from './components/StorageBreakdown';
import styles from './index.less';
const StatsDashboard: React.FC = () => {
const { stats, loading, fetchStatistics, refreshStatistics } = useModel('statistics');
useEffect(() => {
fetchStatistics().catch((error) => {
message.error('Failed to load statistics');
console.error('Error loading statistics:', error);
});
}, []);
const handleRefresh = async () => {
try {
await refreshStatistics();
message.success('Statistics refreshed');
} catch (error) {
message.error('Failed to refresh statistics');
console.error('Error refreshing statistics:', error);
}
};
return (
<PageContainer
title="Personal Statistics"
subTitle="View your content analytics and insights"
extra={[
<Button
key="refresh"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
>
Refresh
</Button>,
]}
>
<Spin spinning={loading}>
<div className={styles.dashboard}>
{stats && (
<>
<StatsOverview stats={stats} />
<div className={styles.charts}>
<UploadChart uploadTrends={stats.uploadTrends} />
<StorageBreakdown storageBreakdown={stats.storageBreakdown} />
</div>
</>
)}
</div>
</Spin>
</PageContainer>
);
};
export default StatsDashboard;

136
src/pages/albums/[id].tsx Normal file
View File

@@ -0,0 +1,136 @@
/**
* Album Detail Page
* Feature: personal-user-enhancements
* Requirements: 2.1, 2.4, 2.5
*/
import React, { useEffect, useState } from 'react';
import { PageContainer } from '@ant-design/pro-components';
import { Button, Spin, Empty, message, Modal, Dropdown, Typography } from 'antd';
import {
EditOutlined,
DeleteOutlined,
PlusOutlined,
MoreOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons';
import { useModel, history, useParams } from '@umijs/max';
import PhotoGrid from './components/PhotoGrid';
import styles from './index.less';
const { Paragraph } = Typography;
const AlbumDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { currentAlbum, loading, fetchAlbumById, deleteAlbum } = useModel('albums');
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
useEffect(() => {
if (id) {
fetchAlbumById(id).catch((error) => {
message.error('Failed to load album');
console.error('Error loading album:', error);
});
}
}, [id]);
const handleEdit = () => {
history.push(`/albums/${id}/edit`);
};
const handleAddPhotos = () => {
history.push(`/albums/${id}/add-photos`);
};
const handleDelete = async () => {
try {
await deleteAlbum(id!);
message.success('Album deleted successfully');
history.push('/albums');
} catch (error) {
message.error('Failed to delete album');
console.error('Error deleting album:', error);
}
};
const handleBack = () => {
history.push('/albums');
};
const menuItems = [
{
key: 'edit',
label: 'Edit Album',
icon: <EditOutlined />,
onClick: handleEdit,
},
{
key: 'delete',
label: 'Delete Album',
icon: <DeleteOutlined />,
danger: true,
onClick: () => setDeleteModalVisible(true),
},
];
if (loading || !currentAlbum) {
return (
<PageContainer>
<Spin spinning={true} />
</PageContainer>
);
}
return (
<PageContainer
title={currentAlbum.name}
subTitle={
<Paragraph style={{ marginBottom: 0, color: 'rgba(0, 0, 0, 0.45)' }}>
{currentAlbum.photoCount} {currentAlbum.photoCount === 1 ? 'photo' : 'photos'}
</Paragraph>
}
extra={[
<Button key="back" icon={<ArrowLeftOutlined />} onClick={handleBack}>
Back
</Button>,
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={handleAddPhotos}>
Add Photos
</Button>,
<Dropdown key="more" menu={{ items: menuItems }} placement="bottomRight">
<Button icon={<MoreOutlined />} />
</Dropdown>,
]}
>
{currentAlbum.description && (
<Paragraph style={{ marginBottom: 24 }}>{currentAlbum.description}</Paragraph>
)}
{currentAlbum.photos.length === 0 ? (
<Empty
description="No photos in this album"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddPhotos}>
Add Photos
</Button>
</Empty>
) : (
<PhotoGrid albumId={id!} photos={currentAlbum.photos} />
)}
<Modal
title="Delete Album"
open={deleteModalVisible}
onOk={handleDelete}
onCancel={() => setDeleteModalVisible(false)}
okText="Delete"
okButtonProps={{ danger: true }}
>
<p>Are you sure you want to delete this album?</p>
<p>The photos will not be deleted, only the album.</p>
</Modal>
</PageContainer>
);
};
export default AlbumDetail;

View File

@@ -0,0 +1,669 @@
/**
* Property Tests for Album Management Frontend
* Feature: personal-user-enhancements
*
* These tests verify correctness properties for Album Management functionality.
*/
import * as fc from 'fast-check';
import {
albumArb,
createAlbumDTOArb,
albumNameArb,
albumDescriptionArb,
photoIdArb,
photoIdsArrayArb,
albumIdArb,
} from '@/utils/test/generators';
import { hasNoDuplicates, haveSameElements } from '@/utils/test/helpers';
describe('Album Management Property Tests', () => {
/**
* Feature: personal-user-enhancements
* Property 4: Album creation with required fields
*
* For any valid album name (non-empty string), creating an album should result
* in a retrievable album with that name and optional description.
*
* Validates: Requirements 2.1
*/
describe('Property 4: Album creation with required fields', () => {
it('should create album with valid name', () => {
fc.assert(
fc.property(
albumNameArb(),
albumDescriptionArb(),
(name, description) => {
// Create album DTO
const createDTO: API.CreateAlbumDTO = {
name,
description,
};
// Simulate album creation response
const createdAlbum: API.Album = {
id: 'test-album-id',
userId: 'test-user-id',
name: createDTO.name,
description: createDTO.description,
photoCount: 0,
photos: [],
createdAt: new Date(),
updatedAt: new Date(),
};
// Verify album has the correct name and description
return (
createdAlbum.name === name &&
createdAlbum.description === description &&
createdAlbum.photoCount === 0 &&
createdAlbum.photos.length === 0
);
},
),
{ numRuns: 100 },
);
});
it('should create album with name only (no description)', () => {
fc.assert(
fc.property(albumNameArb(), (name) => {
// Create album DTO without description
const createDTO: API.CreateAlbumDTO = {
name,
};
// Simulate album creation response
const createdAlbum: API.Album = {
id: 'test-album-id',
userId: 'test-user-id',
name: createDTO.name,
description: undefined,
photoCount: 0,
photos: [],
createdAt: new Date(),
updatedAt: new Date(),
};
// Verify album has the correct name and no description
return (
createdAlbum.name === name &&
createdAlbum.description === undefined &&
createdAlbum.photoCount === 0
);
}),
{ numRuns: 100 },
);
});
it('should preserve album name exactly as provided', () => {
fc.assert(
fc.property(albumNameArb(), (name) => {
// Create album
const createDTO: API.CreateAlbumDTO = { name };
const createdAlbum: API.Album = {
id: 'test-album-id',
userId: 'test-user-id',
name: createDTO.name,
photoCount: 0,
photos: [],
createdAt: new Date(),
updatedAt: new Date(),
};
// Verify name is preserved exactly (no trimming, case changes, etc.)
return createdAlbum.name === name;
}),
{ numRuns: 100 },
);
});
it('should initialize album with zero photos', () => {
fc.assert(
fc.property(createAlbumDTOArb(), (createDTO) => {
// Create album
const createdAlbum: API.Album = {
id: 'test-album-id',
userId: 'test-user-id',
name: createDTO.name,
description: createDTO.description,
coverPhotoId: createDTO.coverPhotoId,
photoCount: 0,
photos: [],
createdAt: new Date(),
updatedAt: new Date(),
};
// Verify album starts with no photos
return createdAlbum.photoCount === 0 && createdAlbum.photos.length === 0;
}),
{ numRuns: 100 },
);
});
it('should generate unique album ID for each creation', () => {
fc.assert(
fc.property(
fc.array(albumNameArb(), { minLength: 2, maxLength: 10 }),
(names) => {
// Create multiple albums
const createdAlbums = names.map((name, index) => ({
id: `album-${index}`,
userId: 'test-user-id',
name,
photoCount: 0,
photos: [],
createdAt: new Date(),
updatedAt: new Date(),
}));
// Verify all album IDs are unique
const ids = createdAlbums.map((a) => a.id);
return hasNoDuplicates(ids);
},
),
{ numRuns: 100 },
);
});
});
/**
* Feature: personal-user-enhancements
* Property 7: Photo reordering persistence
*
* For any album and any valid reordering of its photos, after applying the
* reorder operation, retrieving the album should return photos in the new order.
*
* Validates: Requirements 2.4
*/
describe('Property 7: Photo reordering persistence', () => {
it('should persist photo order after reordering', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has no photos
if (album.photos.length === 0) return true;
// Create a new order by reversing the current order
const originalOrder = album.photos.map((p) => p.photoId);
const newOrder = [...originalOrder].reverse();
// Simulate reorder operation
const reorderedPhotos = newOrder.map((photoId, index) => {
const photo = album.photos.find((p) => p.photoId === photoId)!;
return {
...photo,
order: index,
};
});
const updatedAlbum: API.Album = {
...album,
photos: reorderedPhotos,
};
// Verify photos are in the new order
const resultOrder = updatedAlbum.photos.map((p) => p.photoId);
return JSON.stringify(resultOrder) === JSON.stringify(newOrder);
},
),
{ numRuns: 100 },
);
});
it('should maintain all photos after reordering (no loss)', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has no photos
if (album.photos.length === 0) return true;
// Create a shuffled order
const originalPhotoIds = album.photos.map((p) => p.photoId);
const shuffledOrder = [...originalPhotoIds].sort(() => Math.random() - 0.5);
// Simulate reorder operation
const reorderedPhotos = shuffledOrder.map((photoId, index) => {
const photo = album.photos.find((p) => p.photoId === photoId)!;
return {
...photo,
order: index,
};
});
const updatedAlbum: API.Album = {
...album,
photos: reorderedPhotos,
};
// Verify all photos are still present (same set of IDs)
const originalIds = album.photos.map((p) => p.photoId);
const reorderedIds = updatedAlbum.photos.map((p) => p.photoId);
return haveSameElements(originalIds, reorderedIds);
},
),
{ numRuns: 100 },
);
});
it('should update order field to match position', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has no photos
if (album.photos.length === 0) return true;
// Create a new order
const newOrder = album.photos.map((p) => p.photoId).reverse();
// Simulate reorder operation with explicit order values
const reorderedPhotos = newOrder.map((photoId, index) => {
const photo = album.photos.find((p) => p.photoId === photoId)!;
return {
...photo,
order: index, // Order should match array index
};
});
// Verify order field matches position in array
return reorderedPhotos.every((photo, index) => photo.order === index);
},
),
{ numRuns: 100 },
);
});
it('should handle reordering with single photo (no-op)', () => {
fc.assert(
fc.property(
albumIdArb(),
photoIdArb(),
(albumId, photoId) => {
// Create album with single photo
const album: API.Album = {
id: albumId,
userId: 'test-user-id',
name: 'Test Album',
photoCount: 1,
photos: [
{
id: 'photo-entry-1',
photoId,
albumId,
order: 0,
photoUrl: 'https://example.com/photo.jpg',
thumbnailUrl: 'https://example.com/thumb.jpg',
addedAt: new Date(),
},
],
createdAt: new Date(),
updatedAt: new Date(),
};
// "Reorder" with same order
const newOrder = [photoId];
// Verify album remains unchanged
const resultOrder = album.photos.map((p) => p.photoId);
return JSON.stringify(resultOrder) === JSON.stringify(newOrder);
},
),
{ numRuns: 100 },
);
});
it('should handle reordering empty album (no-op)', () => {
fc.assert(
fc.property(albumIdArb(), (albumId) => {
// Create empty album
const album: API.Album = {
id: albumId,
userId: 'test-user-id',
name: 'Empty Album',
photoCount: 0,
photos: [],
createdAt: new Date(),
updatedAt: new Date(),
};
// Attempt to reorder with empty array
const newOrder: string[] = [];
// Verify album remains empty
return album.photos.length === 0 && newOrder.length === 0;
}),
{ numRuns: 100 },
);
});
it('should preserve photo metadata during reordering', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has no photos
if (album.photos.length === 0) return true;
// Store original photo metadata
const originalMetadata = album.photos.map((p) => ({
photoId: p.photoId,
photoUrl: p.photoUrl,
thumbnailUrl: p.thumbnailUrl,
addedAt: p.addedAt,
}));
// Create a new order
const newOrder = album.photos.map((p) => p.photoId).reverse();
// Simulate reorder operation
const reorderedPhotos = newOrder.map((photoId, index) => {
const photo = album.photos.find((p) => p.photoId === photoId)!;
return {
...photo,
order: index,
};
});
// Verify all metadata is preserved (only order changed)
return reorderedPhotos.every((photo) => {
const original = originalMetadata.find((m) => m.photoId === photo.photoId);
if (!original) return false;
// Handle NaN dates (both should be NaN or both should be valid)
const photoTime = photo.addedAt.getTime();
const originalTime = original.addedAt.getTime();
const datesMatch = (isNaN(photoTime) && isNaN(originalTime)) || photoTime === originalTime;
return (
photo.photoUrl === original.photoUrl &&
photo.thumbnailUrl === original.thumbnailUrl &&
datesMatch
);
});
},
),
{ numRuns: 100 },
);
});
});
/**
* Feature: personal-user-enhancements
* Property 9: Cover photo assignment
*
* For any album and any photo in that album, setting that photo as the cover
* should result in the album's cover photo ID matching that photo's ID.
*
* Validates: Requirements 2.6
*/
describe('Property 9: Cover photo assignment', () => {
it('should set cover photo ID when photo is in album', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has no photos
if (album.photos.length === 0) return true;
// Select a random photo from the album
const photoIndex = Math.floor(Math.random() * album.photos.length);
const selectedPhoto = album.photos[photoIndex];
// Simulate setting cover photo
const updatedAlbum: API.Album = {
...album,
coverPhotoId: selectedPhoto.photoId,
coverPhotoUrl: selectedPhoto.photoUrl,
};
// Verify cover photo ID matches selected photo
return (
updatedAlbum.coverPhotoId === selectedPhoto.photoId &&
updatedAlbum.coverPhotoUrl === selectedPhoto.photoUrl
);
},
),
{ numRuns: 100 },
);
});
it('should update cover photo when changed', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has less than 2 photos
if (album.photos.length < 2) return true;
// Set initial cover photo
const firstPhoto = album.photos[0];
const albumWithCover: API.Album = {
...album,
coverPhotoId: firstPhoto.photoId,
coverPhotoUrl: firstPhoto.photoUrl,
};
// Change to different cover photo
const secondPhoto = album.photos[1];
const updatedAlbum: API.Album = {
...albumWithCover,
coverPhotoId: secondPhoto.photoId,
coverPhotoUrl: secondPhoto.photoUrl,
};
// Verify cover photo was updated
return (
updatedAlbum.coverPhotoId === secondPhoto.photoId &&
updatedAlbum.coverPhotoId !== firstPhoto.photoId
);
},
),
{ numRuns: 100 },
);
});
it('should allow clearing cover photo', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has no photos
if (album.photos.length === 0) return true;
// Set initial cover photo
const photo = album.photos[0];
const albumWithCover: API.Album = {
...album,
coverPhotoId: photo.photoId,
coverPhotoUrl: photo.photoUrl,
};
// Clear cover photo
const updatedAlbum: API.Album = {
...albumWithCover,
coverPhotoId: undefined,
coverPhotoUrl: undefined,
};
// Verify cover photo was cleared
return (
updatedAlbum.coverPhotoId === undefined &&
updatedAlbum.coverPhotoUrl === undefined
);
},
),
{ numRuns: 100 },
);
});
it('should preserve cover photo URL with cover photo ID', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has no photos
if (album.photos.length === 0) return true;
// Select a photo and set as cover
const photo = album.photos[0];
const updatedAlbum: API.Album = {
...album,
coverPhotoId: photo.photoId,
coverPhotoUrl: photo.photoUrl,
};
// Verify both ID and URL are set and match the photo
return (
updatedAlbum.coverPhotoId === photo.photoId &&
updatedAlbum.coverPhotoUrl === photo.photoUrl
);
},
),
{ numRuns: 100 },
);
});
it('should not affect other album properties when setting cover', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has no photos
if (album.photos.length === 0) return true;
// Store original properties
const originalName = album.name;
const originalDescription = album.description;
const originalPhotoCount = album.photoCount;
const originalPhotosLength = album.photos.length;
// Set cover photo
const photo = album.photos[0];
const updatedAlbum: API.Album = {
...album,
coverPhotoId: photo.photoId,
coverPhotoUrl: photo.photoUrl,
};
// Verify other properties unchanged
return (
updatedAlbum.name === originalName &&
updatedAlbum.description === originalDescription &&
updatedAlbum.photoCount === originalPhotoCount &&
updatedAlbum.photos.length === originalPhotosLength
);
},
),
{ numRuns: 100 },
);
});
it('should handle setting same photo as cover multiple times (idempotent)', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has no photos
if (album.photos.length === 0) return true;
// Set cover photo
const photo = album.photos[0];
const firstUpdate: API.Album = {
...album,
coverPhotoId: photo.photoId,
coverPhotoUrl: photo.photoUrl,
};
// Set same cover photo again
const secondUpdate: API.Album = {
...firstUpdate,
coverPhotoId: photo.photoId,
coverPhotoUrl: photo.photoUrl,
};
// Verify result is the same (idempotent operation)
return (
firstUpdate.coverPhotoId === secondUpdate.coverPhotoId &&
firstUpdate.coverPhotoUrl === secondUpdate.coverPhotoUrl
);
},
),
{ numRuns: 100 },
);
});
});
/**
* Additional property tests for album operations
*/
describe('Additional Album Properties', () => {
it('should maintain photo count consistency with photos array', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Photo count should always match photos array length
return album.photoCount === album.photos.length;
},
),
{ numRuns: 100 },
);
});
it('should have unique photo IDs within an album', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// All photo IDs should be unique
const photoIds = album.photos.map((p) => p.photoId);
return hasNoDuplicates(photoIds);
},
),
{ numRuns: 100 },
);
});
it('should preserve album ID across operations', () => {
fc.assert(
fc.property(
albumArb(),
albumNameArb(),
(album, newName) => {
// Simulate update operation
const updatedAlbum: API.Album = {
...album,
name: newName,
};
// Album ID should never change
return updatedAlbum.id === album.id;
},
),
{ numRuns: 100 },
);
});
it('should maintain chronological order of addedAt timestamps', () => {
fc.assert(
fc.property(
albumArb(),
(album) => {
// Skip if album has less than 2 photos
if (album.photos.length < 2) return true;
// Photos should be ordered by their order field, not addedAt
// But addedAt should be valid timestamps
return album.photos.every((photo) => photo.addedAt instanceof Date);
},
),
{ numRuns: 100 },
);
});
});
});

View File

@@ -0,0 +1,56 @@
/**
* Add Photos to Album Page
* Feature: personal-user-enhancements
* Requirements: 2.2
*/
import React, { useEffect } from 'react';
import { PageContainer } from '@ant-design/pro-components';
import { Card, Spin, message } from 'antd';
import { history, useParams, useModel } from '@umijs/max';
import PhotoSelector from '../components/PhotoSelector';
const AddPhotosToAlbum: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { currentAlbum, loading, fetchAlbumById } = useModel('albums');
useEffect(() => {
if (id) {
fetchAlbumById(id).catch((error) => {
message.error('Failed to load album');
console.error('Error loading album:', error);
});
}
}, [id]);
const handleSuccess = () => {
history.push(`/albums/${id}`);
};
const handleCancel = () => {
history.push(`/albums/${id}`);
};
if (loading || !currentAlbum) {
return (
<PageContainer>
<Spin spinning={true} />
</PageContainer>
);
}
return (
<PageContainer title={`Add Photos to ${currentAlbum.name}`}>
<Card>
<PhotoSelector
albumId={id!}
existingPhotoIds={currentAlbum.photos.map((p) => p.photoId)}
onSuccess={handleSuccess}
onCancel={handleCancel}
/>
</Card>
</PageContainer>
);
};
export default AddPhotosToAlbum;

View File

@@ -0,0 +1,63 @@
/**
* Album Card Component
* Feature: personal-user-enhancements
* Requirements: 2.1, 2.5
*/
import React from 'react';
import { Card, Typography } from 'antd';
import { history } from '@umijs/max';
import { PictureOutlined } from '@ant-design/icons';
import styles from '../index.less';
const { Paragraph } = Typography;
interface AlbumCardProps {
album: API.Album;
}
const AlbumCard: React.FC<AlbumCardProps> = ({ album }) => {
const handleClick = () => {
history.push(`/albums/${album.id}`);
};
return (
<Card
hoverable
className={styles.albumCard}
onClick={handleClick}
cover={
album.coverPhotoUrl ? (
<img
alt={album.name}
src={album.coverPhotoUrl}
className={styles.albumThumbnail}
/>
) : (
<div className={styles.albumThumbnail}>
<PictureOutlined style={{ fontSize: 48, color: 'rgba(255, 255, 255, 0.8)' }} />
</div>
)
}
>
<div className={styles.albumInfo}>
<div className={styles.albumName}>{album.name}</div>
{album.description && (
<Paragraph
className={styles.albumDescription}
ellipsis={{ rows: 2 }}
>
{album.description}
</Paragraph>
)}
<div className={styles.albumMeta}>
<span className={styles.albumCount}>
<PictureOutlined /> {album.photoCount} {album.photoCount === 1 ? 'photo' : 'photos'}
</span>
</div>
</div>
</Card>
);
};
export default AlbumCard;

View File

@@ -0,0 +1,94 @@
/**
* Album Editor Component
* Feature: personal-user-enhancements
* Requirements: 2.1, 2.6
*/
import React, { useState } from 'react';
import { Form, Input, Button, message } from 'antd';
import { useModel } from '@umijs/max';
const { TextArea } = Input;
interface AlbumEditorProps {
album?: API.Album;
onSuccess: (albumId: string) => void;
onCancel: () => void;
}
const AlbumEditor: React.FC<AlbumEditorProps> = ({ album, onSuccess, onCancel }) => {
const { createAlbum, updateAlbum } = useModel('albums');
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const isEditMode = !!album;
const handleSubmit = async (values: API.CreateAlbumDTO) => {
setLoading(true);
try {
if (isEditMode) {
const updated = await updateAlbum(album.id, values);
message.success('Album updated successfully');
onSuccess(updated.id);
} else {
const created = await createAlbum(values);
message.success('Album created successfully');
onSuccess(created.id);
}
} catch (error) {
message.error(isEditMode ? 'Failed to update album' : 'Failed to create album');
console.error('Error saving album:', error);
} finally {
setLoading(false);
}
};
return (
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={
album
? {
name: album.name,
description: album.description,
}
: {}
}
>
<Form.Item
name="name"
label="Album Name"
rules={[
{ required: true, message: 'Please enter album name' },
{ max: 100, message: 'Album name cannot exceed 100 characters' },
]}
>
<Input placeholder="Enter album name" />
</Form.Item>
<Form.Item
name="description"
label="Description"
rules={[{ max: 500, message: 'Description cannot exceed 500 characters' }]}
>
<TextArea
rows={4}
placeholder="Enter album description (optional)"
showCount
maxLength={500}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} style={{ marginRight: 8 }}>
{isEditMode ? 'Update Album' : 'Create Album'}
</Button>
<Button onClick={onCancel}>Cancel</Button>
</Form.Item>
</Form>
);
};
export default AlbumEditor;

View File

@@ -0,0 +1,200 @@
/**
* Photo Grid Component with Drag-and-Drop Reordering
* Feature: personal-user-enhancements
* Requirements: 2.4
*/
import React, { useState } from 'react';
import { message, Button, Modal } from 'antd';
import { DeleteOutlined, StarOutlined, StarFilled } from '@ant-design/icons';
import { useModel } from '@umijs/max';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import styles from '../index.less';
interface PhotoGridProps {
albumId: string;
photos: API.AlbumPhoto[];
}
interface SortablePhotoProps {
photo: API.AlbumPhoto;
isCover: boolean;
onSetCover: (photoId: string) => void;
onRemove: (photoId: string) => void;
}
const SortablePhoto: React.FC<SortablePhotoProps> = ({ photo, isCover, onSetCover, onRemove }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: photo.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={styles.photoItem}
>
<img src={photo.thumbnailUrl} alt="" />
<div className={styles.photoActions}>
<Button
type="primary"
size="small"
icon={isCover ? <StarFilled /> : <StarOutlined />}
onClick={(e) => {
e.stopPropagation();
onSetCover(photo.photoId);
}}
title={isCover ? 'Cover photo' : 'Set as cover'}
/>
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
onRemove(photo.photoId);
}}
title="Remove from album"
/>
</div>
</div>
);
};
const PhotoGrid: React.FC<PhotoGridProps> = ({ albumId, photos }) => {
const { currentAlbum, reorderPhotos, removePhotosFromAlbum, setAlbumCover } = useModel('albums');
const [localPhotos, setLocalPhotos] = useState(photos);
const [removeModalVisible, setRemoveModalVisible] = useState(false);
const [photoToRemove, setPhotoToRemove] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = localPhotos.findIndex((p) => p.id === active.id);
const newIndex = localPhotos.findIndex((p) => p.id === over.id);
const newPhotos = arrayMove(localPhotos, oldIndex, newIndex);
setLocalPhotos(newPhotos);
// Optimistic update
try {
const photoOrder = newPhotos.map((p) => p.photoId);
await reorderPhotos(albumId, photoOrder);
message.success('Photos reordered successfully');
} catch (error) {
// Rollback on failure
setLocalPhotos(localPhotos);
message.error('Failed to reorder photos');
console.error('Error reordering photos:', error);
}
}
};
const handleSetCover = async (photoId: string) => {
try {
await setAlbumCover(albumId, photoId);
message.success('Cover photo updated');
} catch (error) {
message.error('Failed to set cover photo');
console.error('Error setting cover photo:', error);
}
};
const handleRemoveClick = (photoId: string) => {
setPhotoToRemove(photoId);
setRemoveModalVisible(true);
};
const handleRemoveConfirm = async () => {
if (!photoToRemove) return;
try {
await removePhotosFromAlbum(albumId, [photoToRemove]);
setLocalPhotos(localPhotos.filter((p) => p.photoId !== photoToRemove));
message.success('Photo removed from album');
setRemoveModalVisible(false);
setPhotoToRemove(null);
} catch (error) {
message.error('Failed to remove photo');
console.error('Error removing photo:', error);
}
};
return (
<>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={localPhotos.map((p) => p.id)} strategy={rectSortingStrategy}>
<div className={styles.photoGrid}>
{localPhotos.map((photo) => (
<SortablePhoto
key={photo.id}
photo={photo}
isCover={photo.photoId === currentAlbum?.coverPhotoId}
onSetCover={handleSetCover}
onRemove={handleRemoveClick}
/>
))}
</div>
</SortableContext>
</DndContext>
<Modal
title="Remove Photo"
open={removeModalVisible}
onOk={handleRemoveConfirm}
onCancel={() => {
setRemoveModalVisible(false);
setPhotoToRemove(null);
}}
okText="Remove"
okButtonProps={{ danger: true }}
>
<p>Are you sure you want to remove this photo from the album?</p>
<p>The photo will not be deleted, only removed from this album.</p>
</Modal>
</>
);
};
export default PhotoGrid;

View File

@@ -0,0 +1,169 @@
/**
* Photo Selector Component with Multi-Select
* Feature: personal-user-enhancements
* Requirements: 2.2
*/
import React, { useState, useEffect } from 'react';
import { Button, message, Spin, Empty, Checkbox } from 'antd';
import { useModel, request } from '@umijs/max';
import styles from '../index.less';
interface PhotoSelectorProps {
albumId: string;
existingPhotoIds: string[];
onSuccess: () => void;
onCancel: () => void;
}
interface Photo {
id: string;
url: string;
thumbnailUrl: string;
}
const PhotoSelector: React.FC<PhotoSelectorProps> = ({
albumId,
existingPhotoIds,
onSuccess,
onCancel,
}) => {
const { addPhotosToAlbum } = useModel('albums');
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedPhotoIds, setSelectedPhotoIds] = useState<string[]>([]);
useEffect(() => {
fetchUserPhotos();
}, []);
const fetchUserPhotos = async () => {
setLoading(true);
try {
// Fetch user's photos from gallery
const response = await request('/api/v1/gallery/photos', {
method: 'GET',
params: { pageSize: 100 },
});
// Filter out photos already in the album
const availablePhotos = response.items?.filter(
(photo: Photo) => !existingPhotoIds.includes(photo.id)
) || [];
setPhotos(availablePhotos);
} catch (error) {
message.error('Failed to load photos');
console.error('Error loading photos:', error);
} finally {
setLoading(false);
}
};
const handlePhotoToggle = (photoId: string) => {
setSelectedPhotoIds((prev) =>
prev.includes(photoId)
? prev.filter((id) => id !== photoId)
: [...prev, photoId]
);
};
const handleSelectAll = () => {
if (selectedPhotoIds.length === photos.length) {
setSelectedPhotoIds([]);
} else {
setSelectedPhotoIds(photos.map((p) => p.id));
}
};
const handleSubmit = async () => {
if (selectedPhotoIds.length === 0) {
message.warning('Please select at least one photo');
return;
}
setSubmitting(true);
try {
await addPhotosToAlbum(albumId, selectedPhotoIds);
message.success(`${selectedPhotoIds.length} photo(s) added to album`);
onSuccess();
} catch (error) {
message.error('Failed to add photos to album');
console.error('Error adding photos:', error);
} finally {
setSubmitting(false);
}
};
if (loading) {
return <Spin spinning={true} />;
}
if (photos.length === 0) {
return (
<Empty
description="No photos available to add"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button onClick={onCancel}>Back to Album</Button>
</Empty>
);
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Checkbox
checked={selectedPhotoIds.length === photos.length}
indeterminate={selectedPhotoIds.length > 0 && selectedPhotoIds.length < photos.length}
onChange={handleSelectAll}
>
Select All ({selectedPhotoIds.length} selected)
</Checkbox>
</div>
<div>
<Button onClick={onCancel} style={{ marginRight: 8 }}>
Cancel
</Button>
<Button
type="primary"
onClick={handleSubmit}
loading={submitting}
disabled={selectedPhotoIds.length === 0}
>
Add {selectedPhotoIds.length > 0 ? `${selectedPhotoIds.length} ` : ''}Photo(s)
</Button>
</div>
</div>
<div className={styles.photoGrid}>
{photos.map((photo) => {
const isSelected = selectedPhotoIds.includes(photo.id);
return (
<div
key={photo.id}
className={`${styles.photoItem} ${isSelected ? styles.photoItemSelected : ''}`}
onClick={() => handlePhotoToggle(photo.id)}
>
<img src={photo.thumbnailUrl} alt="" />
<Checkbox
checked={isSelected}
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 1,
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
);
})}
</div>
</div>
);
};
export default PhotoSelector;

View File

@@ -0,0 +1,31 @@
/**
* Create Album Page
* Feature: personal-user-enhancements
* Requirements: 2.1
*/
import React from 'react';
import { PageContainer } from '@ant-design/pro-components';
import { Card } from 'antd';
import { history } from '@umijs/max';
import AlbumEditor from './components/AlbumEditor';
const CreateAlbum: React.FC = () => {
const handleSuccess = (albumId: string) => {
history.push(`/albums/${albumId}`);
};
const handleCancel = () => {
history.push('/albums');
};
return (
<PageContainer title="Create Album">
<Card>
<AlbumEditor onSuccess={handleSuccess} onCancel={handleCancel} />
</Card>
</PageContainer>
);
};
export default CreateAlbum;

View File

@@ -0,0 +1,55 @@
/**
* Edit Album Page
* Feature: personal-user-enhancements
* Requirements: 2.1
*/
import React, { useEffect } from 'react';
import { PageContainer } from '@ant-design/pro-components';
import { Card, Spin, message } from 'antd';
import { history, useParams, useModel } from '@umijs/max';
import AlbumEditor from '../components/AlbumEditor';
const EditAlbum: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { currentAlbum, loading, fetchAlbumById } = useModel('albums');
useEffect(() => {
if (id) {
fetchAlbumById(id).catch((error) => {
message.error('Failed to load album');
console.error('Error loading album:', error);
});
}
}, [id]);
const handleSuccess = (albumId: string) => {
history.push(`/albums/${albumId}`);
};
const handleCancel = () => {
history.push(`/albums/${id}`);
};
if (loading || !currentAlbum) {
return (
<PageContainer>
<Spin spinning={true} />
</PageContainer>
);
}
return (
<PageContainer title="Edit Album">
<Card>
<AlbumEditor
album={currentAlbum}
onSuccess={handleSuccess}
onCancel={handleCancel}
/>
</Card>
</PageContainer>
);
};
export default EditAlbum;

106
src/pages/albums/index.less Normal file
View File

@@ -0,0 +1,106 @@
.albumCard {
height: 100%;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.albumThumbnail {
width: 100%;
height: 200px;
object-fit: cover;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.albumInfo {
padding: 8px 0;
}
.albumName {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.albumDescription {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.albumMeta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
.albumCount {
color: rgba(0, 0, 0, 0.45);
}
.photoGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-top: 24px;
}
.photoItem {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.photoItemDragging {
opacity: 0.5;
}
.photoItemSelected {
border: 3px solid #1890ff;
}
.photoActions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.3s ease;
.photoItem:hover & {
opacity: 1;
}
}
.emptyPhotos {
text-align: center;
padding: 48px 0;
}

View File

@@ -0,0 +1,63 @@
/**
* Album List Page
* Feature: personal-user-enhancements
* Requirements: 2.1, 2.5
*/
import React, { useEffect } from 'react';
import { PageContainer } from '@ant-design/pro-components';
import { Button, Row, Col, Spin, Empty, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useModel, history } from '@umijs/max';
import AlbumCard from './components/AlbumCard';
import styles from './index.less';
const AlbumList: React.FC = () => {
const { albums, loading, fetchAlbums } = useModel('albums');
useEffect(() => {
fetchAlbums().catch((error) => {
message.error('Failed to load albums');
console.error('Error loading albums:', error);
});
}, []);
const handleCreateAlbum = () => {
history.push('/albums/create');
};
return (
<PageContainer
title="Albums"
subTitle="Organize your photos into albums"
extra={[
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={handleCreateAlbum}>
Create Album
</Button>,
]}
>
<Spin spinning={loading}>
{albums.length === 0 && !loading ? (
<Empty
description="No albums yet"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateAlbum}>
Create Your First Album
</Button>
</Empty>
) : (
<Row gutter={[16, 16]}>
{albums.map((album) => (
<Col xs={24} sm={12} md={8} lg={6} key={album.id}>
<AlbumCard album={album} />
</Col>
))}
</Row>
)}
</Spin>
</PageContainer>
);
};
export default AlbumList;

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

View File

@@ -1,17 +1,9 @@
// src/pages/gallery\components\GridView.tsx
import { ImageItem } from '@/pages/gallery/typings';
import { formatDuration } from '@/utils/timelineUtils';
import { getAuthization } from '@/utils/userUtils';
import {
DeleteOutlined,
DownloadOutlined,
EyeOutlined,
MoreOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Menu, Spin } from 'antd';
import { Spin } from 'antd';
import React, { FC, useCallback, useEffect, useState } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
import PhotoCard from './PhotoCard';
import '../index.css';
interface GridViewProps {
@@ -74,54 +66,6 @@ const GridView: FC<GridViewProps> = ({
const imageSize = getImageSize();
const getImageMenu = useCallback(
(item: ImageItem) => (
<Menu>
<Menu.Item
key="preview"
icon={<EyeOutlined />}
onClick={() => {
const index = imageList.findIndex((img) => img.instanceId === item.instanceId);
onPreview(index);
}}
>
</Menu.Item>
<Menu.Item
key="download"
icon={<DownloadOutlined />}
onClick={() => onDownload(item.instanceId, item.imageName)}
>
</Menu.Item>
<Menu.Divider />
<Menu.Item
key="delete"
icon={<DeleteOutlined />}
danger
onClick={() => onDelete(item.instanceId, item.imageName)}
>
</Menu.Item>
</Menu>
),
[imageList, onPreview, onDownload, onDelete],
);
// 根据视图模式确定图像 URL
const getImageUrl = (item: ImageItem, isHighRes?: boolean) => {
// 如果是视频,使用封面图
if (item.thumbnailInstanceId) {
return `/file/image-low-res/${item.thumbnailInstanceId}?Authorization=${getAuthization()}`;
}
// 小图模式使用低分辨率图像,除非明确要求高清
if (viewMode === 'small' && !isHighRes) {
return `/file/image-low-res/${item.instanceId}?Authorization=${getAuthization()}`;
}
// 其他模式使用原图
return `/file/image/${item.instanceId}?Authorization=${getAuthization()}`;
};
return (
<div
className={viewMode === 'small' ? 'small-grid-view' : 'large-grid-view'}
@@ -134,63 +78,20 @@ const GridView: FC<GridViewProps> = ({
} : {}}
>
{imageList.map((item: ImageItem, index: number) => (
<div
key={item.instanceId}
className="image-card"
style={isMobile ? { width: '100%', margin: 0 } : {}}
>
{batchMode && (
<Checkbox
className="image-checkbox"
checked={selectedRowKeys.includes(item.instanceId)}
onChange={(e) => onSelect(item.instanceId, e.target.checked)}
/>
)}
<div
className="image-wrapper"
style={{
width: isMobile ? '100%' : imageSize.width,
height: imageSize.height,
backgroundImage: `url(${getImageUrl(item, false)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
onClick={() => !batchMode && onPreview(index)}
>
{(item.duration || item.thumbnailInstanceId) && (
<PlayCircleOutlined style={{ fontSize: isMobile ? '24px' : '32px', color: 'rgba(255,255,255,0.8)' }} />
)}
{item.duration && (
<span
style={{
position: 'absolute',
bottom: 4,
right: 4,
background: 'rgba(0,0,0,0.5)',
color: '#fff',
padding: '2px 4px',
borderRadius: 2,
fontSize: 10,
}}
>
{formatDuration(item.duration)}
</span>
)}
</div>
<div className="image-info">
<div className="image-title" title={item.imageName}>
{item.imageName}
</div>
<Dropdown overlay={getImageMenu(item)} trigger={['click']}>
<Button type="text" icon={<MoreOutlined />} size={isMobile ? "small" : "middle"} />
</Dropdown>
</div>
</div>
<PhotoCard
key={item.instanceId}
item={item}
index={index}
viewMode={viewMode}
batchMode={batchMode}
isSelected={selectedRowKeys.includes(item.instanceId)}
imageSize={imageSize}
isMobile={isMobile}
onPreview={onPreview}
onSelect={onSelect}
onDownload={onDownload}
onDelete={onDelete}
/>
))}
{loadingMore && (
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: '20px' }}>

View File

@@ -0,0 +1,183 @@
/**
* PhotoCard Component
* Feature: personal-user-enhancements
*
* Individual photo card with reactions support
*/
import React from 'react';
import { Button, Checkbox, Dropdown, Menu } from 'antd';
import {
DeleteOutlined,
DownloadOutlined,
EyeOutlined,
MoreOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { ReactionBar } from '@/components/Reactions';
import useReactions from '@/hooks/useReactions';
import { ImageItem } from '@/pages/gallery/typings';
import { formatDuration } from '@/utils/timelineUtils';
import { getAuthization } from '@/utils/userUtils';
interface PhotoCardProps {
item: ImageItem;
index: number;
viewMode: 'small' | 'large';
batchMode: boolean;
isSelected: boolean;
imageSize: { width: number | string; height: number };
isMobile: boolean;
onPreview: (index: number) => void;
onSelect: (instanceId: string, checked: boolean) => void;
onDownload: (instanceId: string, imageName: string) => void;
onDelete: (instanceId: string, imageName: string) => void;
}
const PhotoCard: React.FC<PhotoCardProps> = ({
item,
index,
viewMode,
batchMode,
isSelected,
imageSize,
isMobile,
onPreview,
onSelect,
onDownload,
onDelete,
}) => {
// Initialize reactions for this photo
const {
reactions,
addReaction,
updateReaction,
removeReaction,
actionLoading: reactionLoading,
} = useReactions('photo', item.instanceId, {
autoFetch: true,
autoSubscribe: true,
});
// 根据视图模式确定图像 URL
const getImageUrl = (isHighRes?: boolean) => {
// 如果是视频,使用封面图
if (item.thumbnailInstanceId) {
return `/file/image-low-res/${item.thumbnailInstanceId}?Authorization=${getAuthization()}`;
}
// 小图模式使用低分辨率图像,除非明确要求高清
if (viewMode === 'small' && !isHighRes) {
return `/file/image-low-res/${item.instanceId}?Authorization=${getAuthization()}`;
}
// 其他模式使用原图
return `/file/image/${item.instanceId}?Authorization=${getAuthization()}`;
};
const getImageMenu = () => (
<Menu>
<Menu.Item
key="preview"
icon={<EyeOutlined />}
onClick={() => onPreview(index)}
>
</Menu.Item>
<Menu.Item
key="download"
icon={<DownloadOutlined />}
onClick={() => onDownload(item.instanceId, item.imageName)}
>
</Menu.Item>
<Menu.Divider />
<Menu.Item
key="delete"
icon={<DeleteOutlined />}
danger
onClick={() => onDelete(item.instanceId, item.imageName)}
>
</Menu.Item>
</Menu>
);
return (
<div
key={item.instanceId}
className="image-card"
style={isMobile ? { width: '100%', margin: 0 } : {}}
>
{batchMode && (
<Checkbox
className="image-checkbox"
checked={isSelected}
onChange={(e) => onSelect(item.instanceId, e.target.checked)}
/>
)}
<div
className="image-wrapper"
style={{
width: isMobile ? '100%' : imageSize.width,
height: imageSize.height,
backgroundImage: `url(${getImageUrl(false)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
onClick={() => !batchMode && onPreview(index)}
>
{(item.duration || item.thumbnailInstanceId) && (
<PlayCircleOutlined style={{ fontSize: isMobile ? '24px' : '32px', color: 'rgba(255,255,255,0.8)' }} />
)}
{item.duration && (
<span
style={{
position: 'absolute',
bottom: 4,
right: 4,
background: 'rgba(0,0,0,0.5)',
color: '#fff',
padding: '2px 4px',
borderRadius: 2,
fontSize: 10,
}}
>
{formatDuration(item.duration)}
</span>
)}
</div>
<div className="image-info">
<div className="image-title" title={item.imageName}>
{item.imageName}
</div>
<Dropdown overlay={getImageMenu()} trigger={['click']}>
<Button type="text" icon={<MoreOutlined />} size={isMobile ? "small" : "middle"} />
</Dropdown>
</div>
{/* Reactions */}
<div
className="image-reactions"
onClick={(e) => e.stopPropagation()}
style={{ padding: '4px 8px' }}
>
<ReactionBar
entityType="photo"
entityId={item.instanceId}
reactionSummary={reactions || undefined}
onAdd={addReaction}
onRemove={removeReaction}
onChange={updateReaction}
loading={reactionLoading}
size="small"
showPicker={false}
/>
</div>
</div>
);
};
export default PhotoCard;

View File

@@ -0,0 +1,173 @@
/**
* Photo Detail Modal with Comments
* Feature: personal-user-enhancements
*/
import React from 'react';
import { Modal, Image, Space, Typography, Divider } from 'antd';
import { Comments } from '@/components/Comments';
import { ReactionBar } from '@/components/Reactions';
import useComments from '@/hooks/useComments';
import useReactions from '@/hooks/useReactions';
import { ImageItem } from '@/pages/gallery/typings';
import { getAuthization } from '@/utils/userUtils';
const { Title, Text } = Typography;
interface PhotoDetailModalProps {
visible: boolean;
photo: ImageItem | null;
onClose: () => void;
}
const PhotoDetailModal: React.FC<PhotoDetailModalProps> = ({
visible,
photo,
onClose,
}) => {
const {
comments,
loading: commentsLoading,
createLoading,
updateLoading,
deleteLoading,
addComment,
updateComment,
deleteComment,
} = useComments({
entityType: 'photo',
entityId: photo?.instanceId || '',
autoFetch: visible && !!photo,
autoSubscribe: visible && !!photo,
});
const {
reactions,
userReaction,
addReaction,
updateReaction,
removeReaction,
actionLoading: reactionLoading,
} = useReactions('photo', photo?.instanceId || '', {
autoFetch: visible && !!photo,
autoSubscribe: visible && !!photo,
});
// Wrap callbacks to match expected signatures
const handleCreate = async (data: API.CreateCommentDTO) => {
await addComment(data);
};
const handleUpdate = async (id: string, text: string) => {
await updateComment(id, text);
};
const handleDelete = async (id: string) => {
await deleteComment(id);
};
if (!photo) return null;
const formatDate = (dateString?: string) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
return (
<Modal
open={visible}
onCancel={onClose}
footer={null}
width={1000}
centered
destroyOnClose
title={photo.imageName}
>
<div style={{ display: 'flex', gap: '24px', flexDirection: 'column' }}>
{/* Photo Display */}
<div style={{ textAlign: 'center' }}>
<Image
src={`/file/image/${photo.instanceId}?Authorization=${getAuthization()}`}
alt={photo.imageName}
style={{ maxHeight: '500px', maxWidth: '100%' }}
preview={false}
/>
</div>
{/* Photo Info */}
<div>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<div>
<Text type="secondary">: </Text>
<Text>{photo.imageName}</Text>
</div>
{photo.size && (
<div>
<Text type="secondary">: </Text>
<Text>{formatFileSize(photo.size)}</Text>
</div>
)}
{photo.createTime && (
<div>
<Text type="secondary">: </Text>
<Text>{formatDate(photo.createTime)}</Text>
</div>
)}
</Space>
</div>
<Divider style={{ margin: '8px 0' }} />
{/* Reactions Section */}
<div>
<ReactionBar
entityType="photo"
entityId={photo.instanceId}
reactionSummary={reactions || undefined}
onAdd={addReaction}
onRemove={removeReaction}
onChange={updateReaction}
loading={reactionLoading}
size="large"
/>
</div>
<Divider style={{ margin: '8px 0' }} />
{/* Comments Section */}
<div>
<Comments
entityType="photo"
entityId={photo.instanceId}
comments={comments}
loading={commentsLoading}
onCreate={handleCreate}
onEdit={handleUpdate}
onDelete={handleDelete}
createLoading={createLoading}
editLoading={updateLoading}
deleteLoading={deleteLoading}
showCard={false}
title="评论"
/>
</div>
</div>
</Modal>
);
};
export default PhotoDetailModal;

View File

@@ -0,0 +1,79 @@
/**
* PhotoDetailModal Integration Test
* Feature: personal-user-enhancements
* Tests: Comments integration in photo detail view
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import PhotoDetailModal from '../PhotoDetailModal';
import { ImageItem } from '@/pages/gallery/typings';
// Mock dependencies
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('@/utils/userUtils', () => ({
getAuthization: () => 'mock-token',
}));
describe('PhotoDetailModal - Comments Integration', () => {
const mockPhoto: ImageItem = {
instanceId: 'photo-123',
imageName: 'test-photo.jpg',
size: 1024000,
createTime: '2024-01-15T10:30:00Z',
};
const defaultProps = {
visible: true,
photo: mockPhoto,
onClose: jest.fn(),
};
it('should render Comments component when modal is visible', async () => {
render(<PhotoDetailModal {...defaultProps} />);
await waitFor(() => {
// Check if Comments section is rendered
expect(screen.getByText('评论')).toBeInTheDocument();
});
});
it('should display photo information', async () => {
render(<PhotoDetailModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('test-photo.jpg')).toBeInTheDocument();
expect(screen.getByText('0.98 MB')).toBeInTheDocument();
});
});
it('should not render when photo is null', () => {
render(<PhotoDetailModal {...defaultProps} photo={null} />);
// Modal should not render
expect(screen.queryByText('评论')).not.toBeInTheDocument();
});
it('should pass correct entityType and entityId to Comments', async () => {
const { container } = render(<PhotoDetailModal {...defaultProps} />);
await waitFor(() => {
// Verify the modal is rendered
expect(container.querySelector('.ant-modal')).toBeInTheDocument();
});
});
});

View File

@@ -20,6 +20,7 @@ import GalleryTable from './components/GalleryTable';
import GalleryToolbar from './components/GalleryToolbar';
import GridView from './components/GridView';
import ListView from './components/ListView';
import PhotoDetailModal from './components/PhotoDetailModal';
import './index.css';
const Gallery: FC = () => {
@@ -35,6 +36,8 @@ const Gallery: FC = () => {
videoUrl: string;
thumbnailUrl?: string;
} | null>(null);
const [photoDetailVisible, setPhotoDetailVisible] = useState(false);
const [currentPhoto, setCurrentPhoto] = useState<ImageItem | null>(null);
const pageSize = 50;
const initPagination = { current: 1, pageSize: 5 };
@@ -147,8 +150,9 @@ const Gallery: FC = () => {
message.error('获取视频地址失败');
}
} else {
setPreviewCurrent(index);
setPreviewVisible(true);
// Show photo detail modal with comments
setCurrentPhoto(item);
setPhotoDetailVisible(true);
}
},
[imageList],
@@ -580,28 +584,15 @@ const Gallery: FC = () => {
)}
</Modal>
{/* 预览组件 - 使用认证后的图像URL */}
<Image.PreviewGroup
preview={{
visible: previewVisible,
current: previewCurrent,
onVisibleChange: (visible) => setPreviewVisible(visible),
onChange: handlePreviewChange,
{/* Photo Detail Modal with Comments */}
<PhotoDetailModal
visible={photoDetailVisible}
photo={currentPhoto}
onClose={() => {
setPhotoDetailVisible(false);
setCurrentPhoto(null);
}}
>
{imageList.map((item: ImageItem) => (
<Image
key={item.instanceId}
src={
viewMode === 'small'
? `/file/image/${item.instanceId}?Authorization=${getAuthization()}`
: `/file/image-low-res/${item.instanceId}?Authorization=${getAuthization()}`
}
style={{ display: 'none' }}
alt={item.imageName}
/>
))}
</Image.PreviewGroup>
/>
</PageContainer>
</div>
);

View File

@@ -0,0 +1,348 @@
# Offline Editing Integration Guide
This guide explains how to integrate offline editing capabilities into story and album features.
## Overview
The offline editing system provides:
- **Automatic offline detection**: Changes are saved locally when offline
- **Optimistic UI updates**: UI updates immediately, syncs in background
- **Change queue**: All offline changes are queued for synchronization
- **Conflict resolution**: Handles conflicts when syncing with server
- **Sync status indicators**: Shows sync state throughout the UI
## Architecture
```
┌─────────────────┐
│ UI Component │
└────────┬────────┘
┌─────────────────┐
│ Offline Service │ ◄─── Detects online/offline
└────────┬────────┘
├─── Online ──► API Request ──► Server
└─── Offline ─► IndexedDB ──► Sync Queue
(Syncs when online)
```
## Integration Steps
### 1. Use the Sync Model
The sync model provides global sync state and queue management:
```tsx
import { useModel } from '@umijs/max';
function MyComponent() {
const { syncState, queueChange, syncAll, getSyncStatus } = useModel('sync');
// Access sync state
console.log(syncState.isOnline); // true/false
console.log(syncState.isSyncing); // true/false
console.log(syncState.pendingChanges); // number
console.log(getSyncStatus()); // 'synced' | 'pending' | 'syncing' | 'error'
}
```
### 2. Use Offline Services
#### For Stories
```tsx
import { useOfflineStoryService } from '@/pages/story/offlineService';
function StoryEditor() {
const {
addStory,
updateStory,
addStoryItem,
updateStoryItem,
removeStoryItem,
} = useOfflineStoryService();
// These functions automatically handle online/offline scenarios
const handleCreate = async () => {
const story = await addStory({
title: 'My Story',
description: 'Description',
});
// Story is saved locally if offline, synced when online
};
}
```
#### For Albums
```tsx
import { useOfflineAlbumService } from '@/services/albums/offlineService';
function AlbumEditor() {
const {
createAlbum,
updateAlbum,
deleteAlbum,
addPhotosToAlbum,
removePhotosFromAlbum,
reorderPhotos,
setCoverPhoto,
} = useOfflineAlbumService();
// All operations work offline
const handleCreate = async () => {
const album = await createAlbum({
name: 'My Album',
description: 'Description',
});
};
}
```
### 3. Use the Albums Model (Already Integrated)
The albums model has been updated with offline support:
```tsx
import { useModel } from '@umijs/max';
function AlbumPage() {
const {
albums,
currentAlbum,
loading,
createAlbum, // ✓ Offline-aware
updateAlbum, // ✓ Offline-aware
deleteAlbum, // ✓ Offline-aware
addPhotosToAlbum, // ✓ Offline-aware
// ... all methods support offline
} = useModel('albums');
// Just use the model methods as before
// They automatically handle offline scenarios
}
```
### 4. Show Sync Status
The `SyncStatusIndicator` component is already integrated into the app header:
```tsx
// Already added to app.tsx
import { SyncStatusIndicator } from '@/components/SyncStatus/SyncStatusIndicator';
// Shows:
// - Online/offline status
// - Pending changes count
// - Sync button
// - Last sync time
```
### 5. Handle Optimistic Updates
The offline services implement optimistic updates automatically:
```tsx
// When you call an offline-aware function:
await updateAlbum(id, { name: 'New Name' });
// What happens:
// 1. UI updates immediately (optimistic)
// 2. If online: API request → success → done
// 3. If offline: Save to IndexedDB → queue for sync → show message
// 4. When back online: Auto-sync → update server → done
```
## Example: Updating Story Detail Page
Here's how to update the story detail page to use offline editing:
```tsx
// Before (online only)
import { addStoryItem, updateStoryItem } from './service';
const handleAdd = async (formData: FormData) => {
const response = await addStoryItem(formData);
if (response.code === 200) {
message.success('添加成功');
}
};
// After (offline-aware)
import { useOfflineStoryService } from './offlineService';
function StoryDetail() {
const { addStoryItem, updateStoryItem } = useOfflineStoryService();
const handleAdd = async (formData: FormData) => {
try {
const response = await addStoryItem(formData);
// Message is shown automatically by the service
// UI updates optimistically
refresh(); // Refresh the list
} catch (error) {
message.error('操作失败');
}
};
return (
// ... your component JSX
);
}
```
## Testing Offline Functionality
### 1. Simulate Offline Mode
In Chrome DevTools:
1. Open DevTools (F12)
2. Go to Network tab
3. Select "Offline" from the throttling dropdown
### 2. Test Workflow
1. Go offline (Network tab → Offline)
2. Create/edit a story or album
3. Verify the change appears in UI
4. Check that a message shows "已保存到本地,将在联网后同步"
5. Go back online
6. Verify automatic sync occurs
7. Check that changes are on the server
### 3. Check Sync Status
- Look at the sync indicator in the header
- Should show pending changes count when offline
- Should show "syncing" when syncing
- Should show "synced" when complete
## Best Practices
### 1. Always Use Offline Services
```tsx
// ❌ Don't use API directly
import * as api from './service';
await api.addStory(data);
// ✓ Use offline service
import { useOfflineStoryService } from './offlineService';
const { addStory } = useOfflineStoryService();
await addStory(data);
```
### 2. Handle Errors Gracefully
```tsx
try {
await addStory(data);
// Success handled by service
} catch (error) {
// Only handle unexpected errors
message.error('操作失败,请重试');
}
```
### 3. Show Appropriate Messages
The offline services automatically show messages:
- Online success: "操作成功"
- Offline save: "已保存到本地,将在联网后同步"
- Sync success: "成功同步 X 个更改"
- Sync failure: "同步失败: X 个更改未能同步"
### 4. Trust Optimistic Updates
Don't wait for sync to complete before updating UI:
```tsx
// ✓ Good - UI updates immediately
await updateAlbum(id, updates);
// UI already shows the update
// ❌ Bad - waiting for sync
await updateAlbum(id, updates);
await syncAll(); // Unnecessary
```
## Troubleshooting
### Changes Not Syncing
1. Check browser console for errors
2. Verify IndexedDB is enabled
3. Check sync status indicator
4. Try manual sync (click sync button)
### Conflicts
If conflicts occur:
1. ConflictResolver modal will appear
2. Choose: Keep Local, Keep Server, or Merge
3. Conflicts are preserved until resolved
### Performance
- IndexedDB operations are async but fast
- Sync happens in background
- Large files may take time to sync
- Progress is shown in sync indicator
## API Reference
### Sync Model
```typescript
interface SyncState {
isOnline: boolean;
isSyncing: boolean;
pendingChanges: number;
lastSyncTime?: Date;
syncError?: string;
}
// Methods
queueChange(change: ChangeRecord): Promise<void>
syncAll(): Promise<SyncResult | null>
getSyncStatus(): 'synced' | 'pending' | 'syncing' | 'error'
```
### Offline Story Service
```typescript
useOfflineStoryService() {
addStory(params: Partial<StoryType>): Promise<StoryType>
updateStory(params: Partial<StoryType> & { instanceId: string }): Promise<StoryType>
addStoryItem(formData: FormData): Promise<any>
updateStoryItem(formData: FormData): Promise<any>
removeStoryItem(instanceId: string): Promise<any>
}
```
### Offline Album Service
```typescript
useOfflineAlbumService() {
createAlbum(data: CreateAlbumDTO): Promise<Album>
updateAlbum(id: string, data: UpdateAlbumDTO): Promise<Album>
deleteAlbum(id: string): Promise<void>
addPhotosToAlbum(albumId: string, photoIds: string[]): Promise<void>
removePhotosFromAlbum(albumId: string, photoIds: string[]): Promise<void>
reorderPhotos(albumId: string, photoIds: string[]): Promise<void>
setCoverPhoto(albumId: string, photoId: string): Promise<void>
}
```
## Next Steps
1. Update remaining story components to use offline services
2. Add offline support to photo gallery
3. Implement conflict resolution UI improvements
4. Add offline indicators to individual components
5. Test thoroughly with various network conditions

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

View File

@@ -0,0 +1,250 @@
// src/pages/story/offlineService.ts
import { db } from '@/utils/offline/db';
import type { StoryType, StoryItem } from './data.d';
import { useModel } from '@umijs/max';
import * as onlineService from './service';
/**
* Offline-aware wrapper for story operations
* Handles both online and offline scenarios with optimistic updates
*/
/**
* Add a new story (offline-capable)
*/
export async function addStoryOffline(
params: Partial<StoryType>,
isOnline: boolean,
queueChange: (change: any) => Promise<void>,
): Promise<StoryType> {
const tempId = `temp-story-${Date.now()}`;
const story: StoryType = {
instanceId: tempId,
title: params.title || '',
description: params.description,
coverImage: params.coverImage,
userId: params.userId || '',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
itemCount: 0,
permissionType: params.permissionType || 1,
...params,
};
if (isOnline) {
try {
const response = await onlineService.addStory(params);
return response.data.list[0];
} catch (error) {
// If online request fails, fall back to offline
console.warn('Online request failed, saving offline:', error);
}
}
// Save to offline database
await db.stories.add({
...story,
syncStatus: 'pending',
});
// Queue for sync
await queueChange({
userId: story.userId,
entityType: 'story',
entityId: tempId,
operation: 'create',
data: story,
});
return story;
}
/**
* Update an existing story (offline-capable)
*/
export async function updateStoryOffline(
params: Partial<StoryType> & { instanceId: string },
isOnline: boolean,
queueChange: (change: any) => Promise<void>,
): Promise<StoryType> {
const updatedStory = {
...params,
updateTime: new Date().toISOString(),
};
if (isOnline) {
try {
const response = await onlineService.updateStory(params);
// Update local cache
await db.stories.update(params.instanceId, {
...updatedStory,
syncStatus: 'synced',
});
return response.data.list[0];
} catch (error) {
console.warn('Online request failed, saving offline:', error);
}
}
// Update offline database
await db.stories.update(params.instanceId, {
...updatedStory,
syncStatus: 'pending',
});
// Queue for sync
await queueChange({
userId: params.userId || '',
entityType: 'story',
entityId: params.instanceId,
operation: 'update',
data: updatedStory,
});
return updatedStory as StoryType;
}
/**
* Add a story item (offline-capable)
*/
export async function addStoryItemOffline(
formData: FormData,
isOnline: boolean,
queueChange: (change: any) => Promise<void>,
): Promise<any> {
const tempId = `temp-item-${Date.now()}`;
if (isOnline) {
try {
return await onlineService.addStoryItem(formData);
} catch (error) {
console.warn('Online request failed, saving offline:', error);
}
}
// Extract data from FormData for offline storage
const storyItemData: any = {
instanceId: tempId,
storyInstanceId: formData.get('storyInstanceId'),
title: formData.get('title'),
content: formData.get('content'),
storyItemTime: formData.get('storyItemTime'),
createTime: new Date().toISOString(),
syncStatus: 'pending',
};
// Queue for sync with FormData
await queueChange({
userId: '', // Will be filled by sync manager
entityType: 'story-item',
entityId: tempId,
operation: 'create',
data: storyItemData,
formData: formData, // Store FormData for later upload
});
return {
code: 200,
data: storyItemData,
message: '已保存到本地,将在联网后同步',
};
}
/**
* Update a story item (offline-capable)
*/
export async function updateStoryItemOffline(
formData: FormData,
isOnline: boolean,
queueChange: (change: any) => Promise<void>,
): Promise<any> {
const instanceId = formData.get('instanceId') as string;
if (isOnline) {
try {
return await onlineService.updateStoryItem(formData);
} catch (error) {
console.warn('Online request failed, saving offline:', error);
}
}
// Extract data from FormData
const storyItemData: any = {
instanceId,
title: formData.get('title'),
content: formData.get('content'),
storyItemTime: formData.get('storyItemTime'),
updateTime: new Date().toISOString(),
syncStatus: 'pending',
};
// Queue for sync
await queueChange({
userId: '',
entityType: 'story-item',
entityId: instanceId,
operation: 'update',
data: storyItemData,
formData: formData,
});
return {
code: 200,
data: storyItemData,
message: '已保存到本地,将在联网后同步',
};
}
/**
* Delete a story item (offline-capable)
*/
export async function removeStoryItemOffline(
instanceId: string,
isOnline: boolean,
queueChange: (change: any) => Promise<void>,
): Promise<any> {
if (isOnline) {
try {
return await onlineService.removeStoryItem(instanceId);
} catch (error) {
console.warn('Online request failed, saving offline:', error);
}
}
// Queue for sync
await queueChange({
userId: '',
entityType: 'story-item',
entityId: instanceId,
operation: 'delete',
data: { instanceId },
});
return {
code: 200,
message: '已标记删除,将在联网后同步',
};
}
/**
* Hook to use offline-aware story operations
*/
export function useOfflineStoryService() {
const { syncState, queueChange } = useModel('sync');
return {
addStory: (params: Partial<StoryType>) =>
addStoryOffline(params, syncState.isOnline, queueChange),
updateStory: (params: Partial<StoryType> & { instanceId: string }) =>
updateStoryOffline(params, syncState.isOnline, queueChange),
addStoryItem: (formData: FormData) =>
addStoryItemOffline(formData, syncState.isOnline, queueChange),
updateStoryItem: (formData: FormData) =>
updateStoryItemOffline(formData, syncState.isOnline, queueChange),
removeStoryItem: (instanceId: string) =>
removeStoryItemOffline(instanceId, syncState.isOnline, queueChange),
};
}