时间线详情展示重构
This commit is contained in:
@@ -22,7 +22,7 @@ const operationTabList = [
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
(8)
|
||||
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
@@ -37,7 +37,7 @@ const operationTabList = [
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
(8)
|
||||
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
@@ -52,7 +52,7 @@ const operationTabList = [
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
(8)
|
||||
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
|
||||
131
src/pages/story/LAYOUT_EXPLANATION.md
Normal file
131
src/pages/story/LAYOUT_EXPLANATION.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 时间线网格布局说明 - 4列自适应布局
|
||||
|
||||
## 核心布局保证
|
||||
|
||||
### 4列网格系统
|
||||
|
||||
- **基础网格**:`grid-template-columns: repeat(4, 1fr)`
|
||||
- **每行容量**:确保每行能容纳4个1列卡片
|
||||
- **间隔设置**:16px 间隔,不影响列数计算
|
||||
- **对齐方式**:`align-items: start` 和 `justify-items: stretch`
|
||||
|
||||
### 布局验证
|
||||
|
||||
```
|
||||
一行最多容纳的卡片组合示例:
|
||||
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ← 4个1列卡片
|
||||
│ 1格 │ │ 1格 │ │ 1格 │ │ 1格 │
|
||||
└─────┘ └─────┘ └─────┘ └─────┘
|
||||
|
||||
┌───────────┐ ┌───────────┐ ← 2个2列卡片
|
||||
│ 2格 │ │ 2格 │
|
||||
└───────────┘ └───────────┘
|
||||
|
||||
┌─────┐ ┌─────────────────┐ ← 1个1列 + 1个3列卡片
|
||||
│ 1格 │ │ 3格 │
|
||||
└─────┘ └─────────────────┘
|
||||
|
||||
┌─────────────────────────────┐ ← 1个4列卡片(整行)
|
||||
│ 4格 │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
## 单元格占用规则(1-4格)
|
||||
|
||||
### 占据 4 格(4×1,整行)
|
||||
|
||||
- **条件**:6+ 张图片 OR 描述 > 300 字符
|
||||
- **限制**:`Math.min(getGridColumnSpan(), 4)` 确保不超过4格
|
||||
- **特点**:占满整行,最大显示空间
|
||||
|
||||
### 占据 3 格(3×1)
|
||||
|
||||
- **条件**:4-5 张图片 OR 描述 200-300 字符
|
||||
- **剩余空间**:留出1格给其他卡片
|
||||
|
||||
### 占据 2 格(2×1)
|
||||
|
||||
- **条件**:2-3 张图片 OR 描述 100-200 字符
|
||||
- **剩余空间**:留出2格给其他卡片
|
||||
|
||||
### 占据 1 格(1×1)
|
||||
|
||||
- **条件**:< 2 张图片 AND 描述 < 100 字符
|
||||
- **标准大小**:基础单元格
|
||||
|
||||
## 技术实现细节
|
||||
|
||||
### CSS Grid 设置
|
||||
|
||||
```css
|
||||
.timeline-grid-wrapper {
|
||||
display: grid;
|
||||
grid-auto-flow: row; /* 按行填充 */
|
||||
grid-template-columns: repeat(4, 1fr); /* 4等分列 */
|
||||
gap: 16px; /* 16px间隔 */
|
||||
align-items: start; /* 顶部对齐 */
|
||||
justify-items: stretch; /* 拉伸填充 */
|
||||
}
|
||||
```
|
||||
|
||||
### 动态列占用
|
||||
|
||||
```tsx
|
||||
// 计算占用格数(1-4)
|
||||
const columnSpan = Math.min(getGridColumnSpan(), 4);
|
||||
|
||||
// 应用到样式
|
||||
style={{ gridColumn: `span ${columnSpan}` }}
|
||||
```
|
||||
|
||||
### 响应式保证
|
||||
|
||||
- **大屏 (>1024px)**:4列网格,支持1-4格
|
||||
- **中屏 (1024px)**:3列网格,最大3格
|
||||
- **小屏 (768px)**:2列网格,最大2格
|
||||
- **移动 (<480px)**:1列网格,所有卡片1格
|
||||
|
||||
## 调试功能
|
||||
|
||||
### 启用网格线显示
|
||||
|
||||
在开发时,可以在 `timeline-grid-wrapper` 上添加 `debug` 类:
|
||||
|
||||
```tsx
|
||||
<div className="timeline-grid-wrapper debug">
|
||||
```
|
||||
|
||||
这会显示红色半透明的网格线,帮助验证4列布局。
|
||||
|
||||
### 验证方法
|
||||
|
||||
1. 检查4个1列卡片是否在同一行
|
||||
2. 检查2列+2列卡片组合
|
||||
3. 检查1列+3列卡片组合
|
||||
4. 检查4列卡片是否占满整行
|
||||
|
||||
## 排序保证
|
||||
|
||||
### 时间顺序
|
||||
|
||||
- **日期分组**:从新到旧
|
||||
- **组内时间**:从早到晚
|
||||
- **填充方向**:从左到右,从上到下
|
||||
- **不使用 dense**:保持时间顺序,不自动填充空隙
|
||||
|
||||
### 示例时间线
|
||||
|
||||
```
|
||||
2025年12月25日
|
||||
03:00(1) → 03:00(2) → 04:00(1) → 04:00(1)
|
||||
09:30(4格,占满整行)
|
||||
14:00(1) → 18:30(3格) → [空位]
|
||||
21:00(1) → 22:00(1) → 23:00(2格)
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
- **box-sizing: border-box**:确保内边距不影响布局
|
||||
- **max-width: 100%**:防止卡片溢出
|
||||
- **align-items: start**:避免高度不一致问题
|
||||
- **grid-auto-flow: row**:确保按时间顺序排列
|
||||
217
src/pages/story/components/TimelineGridItem.tsx
Normal file
217
src/pages/story/components/TimelineGridItem.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
// TimelineGridItem.tsx - Grid card layout for timeline items
|
||||
import TimelineImage from '@/components/TimelineImage';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useIntl, useRequest } from '@umijs/max';
|
||||
import { Button, message, Popconfirm, Tag, theme } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
import { queryStoryItemImages, removeStoryItem } from '../service';
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
if (!time) return '';
|
||||
|
||||
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
|
||||
if (Array.isArray(time)) {
|
||||
const [, , , hour, minute] = time;
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 如果已经是字符串格式,提取时间部分
|
||||
const timeStr = String(time);
|
||||
const timePart = timeStr.split(' ')[1];
|
||||
if (timePart) {
|
||||
const [hour, minute] = timePart.split(':');
|
||||
return `${hour}:${minute}`;
|
||||
}
|
||||
|
||||
return timeStr;
|
||||
};
|
||||
|
||||
const TimelineGridItem: React.FC<{
|
||||
item: StoryItem;
|
||||
handleOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void;
|
||||
onOpenDetail: (item: StoryItem) => void;
|
||||
refresh: () => void;
|
||||
disableEdit?: boolean;
|
||||
}> = ({ item, handleOption, onOpenDetail, refresh, disableEdit }) => {
|
||||
const intl = useIntl();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// 动态设置CSS变量以适配主题
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// 根据Ant Design的token设置主题变量
|
||||
root.style.setProperty('--timeline-bg', token.colorBgContainer);
|
||||
root.style.setProperty('--timeline-card-bg', token.colorBgElevated);
|
||||
root.style.setProperty('--timeline-card-border', token.colorBorder);
|
||||
root.style.setProperty('--timeline-card-shadow', `0 2px 8px ${token.colorBgMask}`);
|
||||
root.style.setProperty('--timeline-text-primary', token.colorText);
|
||||
root.style.setProperty('--timeline-text-secondary', token.colorTextSecondary);
|
||||
root.style.setProperty('--timeline-text-tertiary', token.colorTextTertiary);
|
||||
root.style.setProperty('--timeline-header-color', token.colorPrimary);
|
||||
root.style.setProperty('--timeline-location-bg', `${token.colorSuccess}1A`); // 10% opacity
|
||||
root.style.setProperty('--timeline-location-border', `${token.colorSuccess}4D`); // 30% opacity
|
||||
root.style.setProperty('--timeline-location-color', token.colorSuccess);
|
||||
root.style.setProperty('--timeline-image-border', token.colorBorder);
|
||||
root.style.setProperty('--timeline-more-bg', token.colorBgMask);
|
||||
root.style.setProperty('--timeline-more-color', token.colorWhite);
|
||||
}, [token]);
|
||||
|
||||
const { data: imagesList } = useRequest(async () => {
|
||||
return await queryStoryItemImages(item.instanceId);
|
||||
}, {
|
||||
refreshDeps: [item.instanceId]
|
||||
});
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
if (!item.instanceId) return;
|
||||
const response = await removeStoryItem(item.instanceId);
|
||||
if (response.code === 200) {
|
||||
message.success(intl.formatMessage({ id: 'story.deleteSuccess' }));
|
||||
refresh();
|
||||
} else {
|
||||
message.error(intl.formatMessage({ id: 'story.deleteFailed' }));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(intl.formatMessage({ id: 'story.deleteFailed' }));
|
||||
}
|
||||
};
|
||||
|
||||
// 动态计算卡片大小(1-4格)
|
||||
const calculateCardSize = () => {
|
||||
const imageCount = imagesList?.length || 0;
|
||||
const descriptionLength = item.description?.length || 0;
|
||||
|
||||
// 根据图片数量和描述长度决定卡片大小
|
||||
if (imageCount >= 4 || descriptionLength > 200) {
|
||||
return 4; // 占据整行
|
||||
} else if (imageCount >= 2 || descriptionLength > 100) {
|
||||
return 2; // 占据2格
|
||||
} else if (imageCount >= 1 && descriptionLength > 50) {
|
||||
return 2; // 有图片且描述较长,占据2格
|
||||
} else {
|
||||
return 1; // 默认占据1格
|
||||
}
|
||||
};
|
||||
|
||||
const cardSize = calculateCardSize();
|
||||
|
||||
// 统一的文本长度 - 根据卡片大小调整
|
||||
const getDescriptionMaxLength = (size: number) => {
|
||||
switch (size) {
|
||||
case 1: return 80;
|
||||
case 2: return 150;
|
||||
case 3: return 200;
|
||||
case 4: return 300;
|
||||
default: return 100;
|
||||
}
|
||||
};
|
||||
|
||||
const descriptionMaxLength = getDescriptionMaxLength(cardSize);
|
||||
|
||||
// 截断描述文本
|
||||
const truncateText = (text: string | undefined, maxLength: number) => {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
};
|
||||
|
||||
// 只返回article元素,不包含任何其他元素
|
||||
return (
|
||||
<article
|
||||
className={`timeline-grid-item size-${cardSize}`}
|
||||
onClick={() => onOpenDetail(item)}
|
||||
>
|
||||
{/* Action buttons */}
|
||||
{!disableEdit && (
|
||||
<div className="item-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOption(item, 'edit');
|
||||
}}
|
||||
aria-label={intl.formatMessage({ id: 'story.edit' })}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={intl.formatMessage({ id: 'story.deleteConfirm' })}
|
||||
description={intl.formatMessage({ id: 'story.deleteConfirmDescription' })}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
okText={intl.formatMessage({ id: 'story.yes' })}
|
||||
cancelText={intl.formatMessage({ id: 'story.no' })}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={intl.formatMessage({ id: 'story.delete' })}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time header */}
|
||||
<h3>{formatTimeArray(item.storyItemTime)}</h3>
|
||||
|
||||
{/* Title */}
|
||||
<div className="item-title">{item.title}</div>
|
||||
|
||||
{/* Description */}
|
||||
<p>{truncateText(item.description, descriptionMaxLength)}</p>
|
||||
|
||||
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
|
||||
{imagesList && imagesList.length > 0 && (
|
||||
<div className="item-images-container">
|
||||
<div className="item-images-row">
|
||||
{imagesList
|
||||
.filter(imageInstanceId => imageInstanceId && imageInstanceId.trim() !== '')
|
||||
.slice(0, 6) // 最多显示6张图片
|
||||
.map((imageInstanceId, index) => (
|
||||
<div key={imageInstanceId + index} className="item-image-wrapper">
|
||||
<TimelineImage
|
||||
title={imageInstanceId}
|
||||
imageInstanceId={imageInstanceId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{imagesList.length > 6 && (
|
||||
<div className="more-images-indicator">
|
||||
+{imagesList.length - 6}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location badge */}
|
||||
{item.location && (
|
||||
<span className="timeline-location-badge">📍 {item.location}</span>
|
||||
)}
|
||||
|
||||
{/* Creator/Updater tags */}
|
||||
<div className="item-tags">
|
||||
{item.createName && (
|
||||
<Tag color="blue" style={{ fontSize: '11px', padding: '2px 8px' }}>
|
||||
{item.createName}
|
||||
</Tag>
|
||||
)}
|
||||
{item.updateName && item.updateName !== item.createName && (
|
||||
<Tag color="green" style={{ fontSize: '11px', padding: '2px 8px' }}>
|
||||
{item.updateName}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineGridItem;
|
||||
@@ -109,15 +109,6 @@ const TimelineItem: React.FC<{
|
||||
}}
|
||||
aria-label={intl.formatMessage({ id: 'story.edit' })}
|
||||
/>
|
||||
{/*<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOption(item, 'addSubItem');
|
||||
}}
|
||||
aria-label={intl.formatMessage({ id: 'story.addSubItem' })}
|
||||
/>*/}
|
||||
<Popconfirm
|
||||
title={intl.formatMessage({ id: 'story.deleteConfirm' })}
|
||||
description={intl.formatMessage({ id: 'story.deleteConfirmDescription' })}
|
||||
@@ -145,8 +136,8 @@ const TimelineItem: React.FC<{
|
||||
<div className={styles.content}>
|
||||
<div className={styles.date} onClick={() => setOpenDetail(true)}>
|
||||
<Space size="small" className={styles.dateInfo}>
|
||||
<span className={styles.time}>{formatTimeArray(item.storyItemTime)}</span>
|
||||
{item.location && <span className={styles.location}>📍 {item.location}</span>}
|
||||
<span className="timeline-date-badge">{formatTimeArray(item.storyItemTime)}</span>
|
||||
{item.location && <span className="timeline-location-badge">📍 {item.location}</span>}
|
||||
</Space>
|
||||
</div>
|
||||
<div className={styles.description} onClick={() => setOpenDetail(true)}>
|
||||
@@ -166,18 +157,16 @@ const TimelineItem: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
{imagesList && imagesList.length > 0 && (
|
||||
<>
|
||||
<div className={styles.timelineItemImages}>
|
||||
{imagesList.map((imageInstanceId, index) => (
|
||||
<TimelineImage
|
||||
key={imageInstanceId + index}
|
||||
title={imageInstanceId}
|
||||
imageInstanceId={imageInstanceId}
|
||||
className={styles.timelineImage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<div className="timeline-images-grid">
|
||||
{imagesList.map((imageInstanceId, index) => (
|
||||
<TimelineImage
|
||||
key={imageInstanceId + index}
|
||||
title={imageInstanceId}
|
||||
imageInstanceId={imageInstanceId}
|
||||
className={styles.timelineImage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{item.subItems && item.subItems.length > 0 && (
|
||||
<div className={styles.subItems}>
|
||||
|
||||
84
src/pages/story/components/TimelineItem/timeline.html
Normal file
84
src/pages/story/components/TimelineItem/timeline.html
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
<main>
|
||||
<h1>Events</h1>
|
||||
<section>
|
||||
<h2>2 December</h2>
|
||||
<div class="grid-wrapper">
|
||||
<article>
|
||||
<h3>9:00 AM</h3>
|
||||
<p>Life finds a way. You know what? It is beets. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>10:00 AM</h3>
|
||||
<p>I've crashed into a beet truck </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>12:30 AM</h3>
|
||||
<p>I was part of something special. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>13:30 AM</h3>
|
||||
<p>Yeah, but your scientists were so preoccupied with whether or not they could, they didn't stop to think if they should. </p>
|
||||
<img src="https://images.fineartamerica.com/images-medium-large-5/maroon-bells-aspen-colorado-black-and-white-photography-by-sai.jpg" alt="Black and white photo of a lake">
|
||||
|
||||
</article>
|
||||
<article>
|
||||
<h3>14:30 AM</h3>
|
||||
<p>Just my luck, no ice. God help us, we're in the hands of engineers. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>15:30 AM</h3>
|
||||
<p>I gave it a cold? I gave it a virus. A computer virus. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>16:30 AM</h3>
|
||||
<p>God creates dinosaurs. God destroys dinosaurs. God creates Man. Man destroys God. Man creates Dinosaurs. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>17:30 AM</h3>
|
||||
<p>What do they got in there? King Kong? </p>
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Eiffel_tower_at_Exposition_Universelle%2C_Paris%2C_1889.jpg/1200px-Eiffel_tower_at_Exposition_Universelle%2C_Paris%2C_1889.jpg" alt="Black and White Eiffel Tower"" alt="Black and white Mountian view">
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>3 Jan</h2>
|
||||
<div class="grid-wrapper">
|
||||
<article>
|
||||
<h3>9:00 AM</h3>
|
||||
<p>Life finds a way. You know what? It is beets. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>10:00 AM</h3>
|
||||
<p>I've crashed into a beet truck </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>12:30 AM</h3>
|
||||
<p>I was part of something special. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>13:30 AM</h3>
|
||||
<p>Yeah, but your scientists were so preoccupied with whether or not they could, they didn't stop to think if they should. </p>
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Eiffel_tower_at_Exposition_Universelle%2C_Paris%2C_1889.jpg/1200px-Eiffel_tower_at_Exposition_Universelle%2C_Paris%2C_1889.jpg" alt="Black and White Eiffel Tower">
|
||||
</article>
|
||||
<article>
|
||||
<h3>14:30 AM</h3>
|
||||
<p>Just my luck, no ice. God help us, we're in the hands of engineers. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>15:30 AM</h3>
|
||||
<p>I gave it a cold? I gave it a virus. A computer virus. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>16:30 AM</h3>
|
||||
<p>God creates dinosaurs. God destroys dinosaurs. God creates Man. Man destroys God. Man creates Dinosaurs. </p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>17:30 AM</h3>
|
||||
<p>What do they got in there? King Kong? </p>
|
||||
<img src="https://images.fineartamerica.com/images-medium-large-5/maroon-bells-aspen-colorado-black-and-white-photography-by-sai.jpg" alt="Black and white Mountian view">
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<p class="footer-note">Design by <a href="https://dribbble.com/shots/8576480-Book-Festival-Responsive-Website">tubik</a></p>
|
||||
</main>
|
||||
109
src/pages/story/components/TimelineItem/timeline.scss
Normal file
109
src/pages/story/components/TimelineItem/timeline.scss
Normal file
@@ -0,0 +1,109 @@
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(to bottom, #1890ff, #52c41a);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 30px;
|
||||
padding-left: 70px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: 21px;
|
||||
top: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #1890ff;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 0 0 2px #1890ff;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.3s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.timeline-description {
|
||||
margin: 10px 0;
|
||||
color: #595959;
|
||||
line-height: 1.6;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-location {
|
||||
margin: 5px 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timeline-images {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeline-image {
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.timeline-tags {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.timeline-actions {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-item:hover .timeline-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { StoryItem } from '@/pages/story/data';
|
||||
import { queryStoryItemImages } from '@/pages/story/service';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useIntl, useRequest } from '@umijs/max';
|
||||
import { Button, Divider, Drawer, Popconfirm, Space, Tag } from 'antd';
|
||||
import { Button, Divider, Drawer, Popconfirm, Space, Tag, theme } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
@@ -33,6 +33,7 @@ interface Props {
|
||||
const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
const { storyItem, open, setOpen, handleDelete, handOption, disableEdit } = props;
|
||||
const intl = useIntl();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const { data: imagesList, run } = useRequest(
|
||||
async (itemId) => {
|
||||
@@ -70,8 +71,8 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 主时间点详情抽屉 */}
|
||||
<>
|
||||
{/* 只在打开时渲染抽屉 */}
|
||||
<Drawer
|
||||
width={800}
|
||||
placement="right"
|
||||
@@ -134,18 +135,28 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
</Space>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<div style={{
|
||||
padding: '0 24px',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
color: token.colorText
|
||||
}}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3>描述</h3>
|
||||
<p style={{ fontSize: '16px', lineHeight: '1.6' }}>{storyItem.description}</p>
|
||||
<h3 style={{ color: token.colorText }}>描述</h3>
|
||||
<p style={{
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
color: token.colorTextSecondary
|
||||
}}>
|
||||
{storyItem.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<Divider style={{ borderColor: token.colorBorder }} />
|
||||
|
||||
{/* 时刻图库 */}
|
||||
{imagesList && imagesList.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3>时刻图库</h3>
|
||||
<h3 style={{ color: token.colorText }}>时刻图库</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -200,7 +211,7 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
519
src/pages/story/detail.css
Normal file
519
src/pages/story/detail.css
Normal file
@@ -0,0 +1,519 @@
|
||||
/* Timeline Detail Page Styles - 主题适配版本 */
|
||||
|
||||
/* CSS变量定义 - 支持主题切换 */
|
||||
:root {
|
||||
/* 日间模式 */
|
||||
--timeline-bg: #ffffff;
|
||||
--timeline-card-bg: #ffffff;
|
||||
--timeline-card-border: rgba(0, 0, 0, 0.06);
|
||||
--timeline-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
--timeline-text-primary: rgba(0, 0, 0, 0.88);
|
||||
--timeline-text-secondary: rgba(0, 0, 0, 0.65);
|
||||
--timeline-text-tertiary: rgba(0, 0, 0, 0.45);
|
||||
--timeline-header-color: #1890ff;
|
||||
--timeline-location-bg: rgba(82, 196, 26, 0.1);
|
||||
--timeline-location-border: rgba(82, 196, 26, 0.3);
|
||||
--timeline-location-color: #52c41a;
|
||||
--timeline-image-border: rgba(0, 0, 0, 0.1);
|
||||
--timeline-more-bg: rgba(0, 0, 0, 0.7);
|
||||
--timeline-more-color: #ffffff;
|
||||
}
|
||||
|
||||
/* 夜间模式 */
|
||||
[data-theme='dark'] {
|
||||
--timeline-bg: #141414;
|
||||
--timeline-card-bg: #1f1f1f;
|
||||
--timeline-card-border: rgba(255, 255, 255, 0.08);
|
||||
--timeline-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--timeline-text-primary: rgba(255, 255, 255, 0.88);
|
||||
--timeline-text-secondary: rgba(255, 255, 255, 0.65);
|
||||
--timeline-text-tertiary: rgba(255, 255, 255, 0.45);
|
||||
--timeline-header-color: #1890ff;
|
||||
--timeline-location-bg: rgba(82, 196, 26, 0.15);
|
||||
--timeline-location-border: rgba(82, 196, 26, 0.3);
|
||||
--timeline-location-color: #52c41a;
|
||||
--timeline-image-border: rgba(255, 255, 255, 0.1);
|
||||
--timeline-more-bg: rgba(0, 0, 0, 0.8);
|
||||
--timeline-more-color: #ffffff;
|
||||
}
|
||||
|
||||
/* 兼容Ant Design的暗色主题 */
|
||||
.ant-pro-layout[data-theme='realDark'],
|
||||
.ant-pro-layout[data-theme='dark'],
|
||||
html[data-theme='dark'] {
|
||||
--timeline-bg: #141414;
|
||||
--timeline-card-bg: #1f1f1f;
|
||||
--timeline-card-border: rgba(255, 255, 255, 0.08);
|
||||
--timeline-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--timeline-text-primary: rgba(255, 255, 255, 0.88);
|
||||
--timeline-text-secondary: rgba(255, 255, 255, 0.65);
|
||||
--timeline-text-tertiary: rgba(255, 255, 255, 0.45);
|
||||
--timeline-header-color: #1890ff;
|
||||
--timeline-location-bg: rgba(82, 196, 26, 0.15);
|
||||
--timeline-location-border: rgba(82, 196, 26, 0.3);
|
||||
--timeline-location-color: #52c41a;
|
||||
--timeline-image-border: rgba(255, 255, 255, 0.1);
|
||||
--timeline-more-bg: rgba(0, 0, 0, 0.8);
|
||||
--timeline-more-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Main container */
|
||||
.timeline {
|
||||
min-height: 100%;
|
||||
background: var(--timeline-bg);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Section headers for date grouping */
|
||||
.timeline-section-header {
|
||||
margin: 30px 0 20px 0;
|
||||
padding-bottom: 12px;
|
||||
color: var(--timeline-header-color);
|
||||
font-weight: 600;
|
||||
font-size: 28px;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
border-bottom: 2px solid var(--timeline-header-color);
|
||||
transition: color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Grid container for timeline items - 动态大小网格布局 */
|
||||
.timeline-grid-wrapper {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* Individual timeline card - 动态大小 */
|
||||
.timeline-grid-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 默认占据1格 */
|
||||
grid-column: span 1;
|
||||
box-sizing: border-box;
|
||||
min-height: 200px;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
background: var(--timeline-card-bg);
|
||||
border: 1px solid var(--timeline-card-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--timeline-card-shadow);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* 动态大小类 */
|
||||
.timeline-grid-item.size-1 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-2 {
|
||||
grid-column: span 2;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-2 h3 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-2 .item-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-2 p {
|
||||
font-size: 14px;
|
||||
-webkit-line-clamp: 5;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-3 {
|
||||
grid-column: span 3;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-3 h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-3 .item-title {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-3 p {
|
||||
font-size: 14px;
|
||||
-webkit-line-clamp: 6;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-4 {
|
||||
grid-column: span 4;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-4 h3 {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-4 .item-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-4 p {
|
||||
font-size: 15px;
|
||||
-webkit-line-clamp: 8;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-grid-item:hover {
|
||||
background: var(--timeline-card-bg);
|
||||
border-color: var(--timeline-header-color);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Card header - time */
|
||||
.timeline-grid-item h3 {
|
||||
margin: 0 0 12px 0;
|
||||
padding-bottom: 10px;
|
||||
color: var(--timeline-text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--timeline-card-border);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Card title */
|
||||
.timeline-grid-item .item-title {
|
||||
display: -webkit-box;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
color: var(--timeline-header-color);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Card description */
|
||||
.timeline-grid-item p {
|
||||
display: -webkit-box;
|
||||
flex: 1;
|
||||
margin: 0 0 12px 0;
|
||||
overflow: hidden;
|
||||
color: var(--timeline-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* Location badge */
|
||||
.timeline-location-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
margin-top: auto;
|
||||
padding: 4px 10px;
|
||||
color: var(--timeline-location-color);
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
background: var(--timeline-location-bg);
|
||||
border: 1px solid var(--timeline-location-border);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Creator/Updater tags */
|
||||
.timeline-grid-item .item-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-tags .ant-tag {
|
||||
margin: 0;
|
||||
padding: 2px 6px;
|
||||
color: #1890ff;
|
||||
font-size: 10px;
|
||||
background: rgba(24, 144, 255, 0.15);
|
||||
border-color: rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Action buttons overlay */
|
||||
.timeline-grid-item .item-actions {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-grid-item:hover .item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-actions .ant-btn {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-actions .ant-btn:hover {
|
||||
color: #1890ff;
|
||||
background: rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-actions .ant-btn-dangerous:hover {
|
||||
color: #ff4d4f;
|
||||
background: rgba(255, 77, 79, 0.2);
|
||||
}
|
||||
|
||||
/* 图片容器 - 固定间隔,单行展示 */
|
||||
.timeline-grid-item .item-images-container {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-images-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* 隐藏滚动条 */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-images-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-image-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--timeline-image-border);
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-image-wrapper .tl-image-container {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-image-wrapper .tl-image-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 不同大小卡片的图片调整 */
|
||||
.timeline-grid-item.size-2 .item-image-wrapper {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-3 .item-image-wrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-4 .item-image-wrapper {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
/* 更多图片指示器 */
|
||||
.timeline-grid-item .more-images-indicator {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--timeline-more-bg);
|
||||
color: var(--timeline-more-color);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--timeline-image-border);
|
||||
backdrop-filter: blur(4px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 不同大小卡片的更多指示器调整 */
|
||||
.timeline-grid-item.size-2 .more-images-indicator {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-3 .more-images-indicator {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.timeline-grid-item.size-4 .more-images-indicator {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Loading and empty states */
|
||||
.timeline .load-indicator,
|
||||
.timeline .no-more-data {
|
||||
padding: 32px;
|
||||
color: var(--timeline-text-tertiary);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline .load-indicator {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments - 动态大小网格 */
|
||||
@media (min-width: 1000px) {
|
||||
.timeline-grid-wrapper {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 999px) and (min-width: 700px) {
|
||||
.timeline-grid-wrapper {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.timeline-section-header {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* 在3列布局中,限制最大跨度 */
|
||||
.timeline-grid-item.size-4 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 699px) and (min-width: 400px) {
|
||||
.timeline-grid-wrapper {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-grid-item {
|
||||
min-height: 180px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.timeline-grid-item h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.timeline-grid-item .item-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timeline-grid-item p {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-section-header {
|
||||
margin: 20px 0 16px 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
/* 在2列布局中,限制最大跨度 */
|
||||
.timeline-grid-item.size-3,
|
||||
.timeline-grid-item.size-4 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* 小屏幕上的图片调整 */
|
||||
.timeline-grid-item .item-image-wrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.timeline-grid-item .more-images-indicator {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.timeline-grid-wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-grid-item {
|
||||
min-height: 160px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.timeline-section-header {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* 在单列布局中,所有卡片都占1格 */
|
||||
.timeline-grid-item.size-1,
|
||||
.timeline-grid-item.size-2,
|
||||
.timeline-grid-item.size-3,
|
||||
.timeline-grid-item.size-4 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
/* 最小屏幕上的图片调整 */
|
||||
.timeline-grid-item .item-image-wrapper {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.timeline-grid-item .more-images-indicator {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
// src/pages/story/detail.tsx
|
||||
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
|
||||
import TimelineItem from '@/pages/story/components/TimelineItem/TimelineItem';
|
||||
import TimelineGridItem from '@/pages/story/components/TimelineGridItem';
|
||||
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
|
||||
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
|
||||
import { queryStoryDetail, queryStoryItem } from '@/pages/story/service';
|
||||
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
|
||||
import { PlusOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { history, useRequest } from '@umijs/max';
|
||||
import { Button, Empty, FloatButton, message, Spin } from 'antd';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import './index.css';
|
||||
import { useParams } from '@umijs/max';
|
||||
import './detail.css';
|
||||
import {judgePermission} from "@/pages/story/utils/utils";
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
@@ -31,10 +30,6 @@ const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
const Index = () => {
|
||||
const { id: lineId } = useParams<{ id: string }>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<any>(null);
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [items, setItems] = useState<StoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMoreOld, setHasMoreOld] = useState(true); // 是否有更老的数据
|
||||
@@ -44,11 +39,9 @@ const Index = () => {
|
||||
const [currentOption, setCurrentOption] = useState<
|
||||
'add' | 'edit' | 'addSubItem' | 'editSubItem'
|
||||
>();
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
||||
// 存储每个item的高度
|
||||
const [itemSizes, setItemSizes] = useState<Record<string, number>>({});
|
||||
// 存储已测量高度的item ID集合
|
||||
const measuredItemsRef = useRef<Set<string>>(new Set());
|
||||
const [openDetailDrawer, setOpenDetailDrawer] = useState(false);
|
||||
const [detailItem, setDetailItem] = useState<StoryItem>();
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 30 });
|
||||
const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
|
||||
const [showScrollTop, setShowScrollTop] = useState(false); // 是否显示回到顶部按钮
|
||||
const [currentTimeArr, setCurrentTimeArr] = useState<[string | undefined, string | undefined]>([
|
||||
@@ -58,10 +51,8 @@ const Index = () => {
|
||||
const [loadDirection, setLoadDirection] = useState<'init' | 'older' | 'newer' | 'refresh'>(
|
||||
'init',
|
||||
);
|
||||
// 添加在其他 useRef 之后
|
||||
const hasShownNoMoreOldRef = useRef(false);
|
||||
const hasShownNoMoreNewRef = useRef(false);
|
||||
const topLoadingRef = useRef(false); // 防止顶部循环加载
|
||||
|
||||
type QueryParams = StoryItemTimeQueryParams & { current?: number; pageSize?: number };
|
||||
|
||||
@@ -86,9 +77,6 @@ const Index = () => {
|
||||
useEffect(() => {
|
||||
setHasMoreOld(true);
|
||||
setHasMoreNew(true);
|
||||
// 重置高度缓存
|
||||
setItemSizes({});
|
||||
measuredItemsRef.current = new Set();
|
||||
setLoadDirection('init');
|
||||
setLoading(true);
|
||||
run({ current: 1 });
|
||||
@@ -106,7 +94,6 @@ const Index = () => {
|
||||
setHasMoreOld(false);
|
||||
} else if (loadDirection === 'newer') {
|
||||
setHasMoreNew(false);
|
||||
topLoadingRef.current = false;
|
||||
}
|
||||
setLoading(false);
|
||||
setIsRefreshing(false);
|
||||
@@ -129,9 +116,6 @@ const Index = () => {
|
||||
setItems((prev) => {
|
||||
const next = [...fetched, ...prev];
|
||||
setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]);
|
||||
if (listRef.current && fetched.length) {
|
||||
requestAnimationFrame(() => listRef.current?.scrollToItem(fetched.length + 1, 'start'));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setHasMoreNew(!noMore);
|
||||
@@ -139,9 +123,8 @@ const Index = () => {
|
||||
hasShownNoMoreNewRef.current = true;
|
||||
message.info('没有更多更新内容了');
|
||||
}
|
||||
topLoadingRef.current = false;
|
||||
} else if (loadDirection === 'refresh') {
|
||||
topLoadingRef.current = false;
|
||||
// 刷新操作
|
||||
} else {
|
||||
setItems(fetched);
|
||||
setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
|
||||
@@ -154,46 +137,9 @@ const Index = () => {
|
||||
setLoadDirection('init');
|
||||
}, [response, loadDirection, pagination.pageSize]);
|
||||
|
||||
// 获取item高度的函数
|
||||
const getItemSize = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (!item) return 400; // 默认高度
|
||||
const key = String(item.id ?? item.instanceId);
|
||||
|
||||
if (itemSizes[key]) {
|
||||
return itemSizes[key];
|
||||
}
|
||||
|
||||
return 400;
|
||||
},
|
||||
[items, itemSizes, loadDirection, loading],
|
||||
);
|
||||
|
||||
// 当item尺寸发生变化时调用
|
||||
const onItemResize = useCallback(
|
||||
(itemId: string, height: number) => {
|
||||
// 只有当高度发生变化时才更新
|
||||
if (itemSizes[itemId] !== height) {
|
||||
setItemSizes((prev) => ({
|
||||
...prev,
|
||||
[itemId]: height,
|
||||
}));
|
||||
|
||||
// 通知List组件重新计算尺寸
|
||||
if (listRef.current) {
|
||||
listRef.current.resetAfterIndex(0);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemSizes],
|
||||
);
|
||||
// 滚动到底部加载更老的数据
|
||||
const loadOlder = useCallback(() => {
|
||||
if (loading || !hasMoreOld) {
|
||||
if (!hasMoreOld && !loading) {
|
||||
message.info('没有更多历史内容了');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const beforeTime = items[items.length - 1]?.storyItemTime || currentTimeArr[1];
|
||||
@@ -205,12 +151,9 @@ const Index = () => {
|
||||
run({ current: nextPage });
|
||||
}, [loading, hasMoreOld, items, currentTimeArr, pagination.current, run]);
|
||||
|
||||
// 滚动到顶部加载更新的数据
|
||||
// 滚动到顶部加载更新的数据
|
||||
const loadNewer = useCallback(() => {
|
||||
if (loading || !hasMoreNew || isRefreshing || topLoadingRef.current) {
|
||||
if (!hasMoreNew && !loading && !isRefreshing) {
|
||||
message.info('没有更多更新内容了');
|
||||
}
|
||||
if (loading || !hasMoreNew || isRefreshing) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -220,122 +163,29 @@ const Index = () => {
|
||||
setLoadDirection('newer');
|
||||
setIsRefreshing(true);
|
||||
setLoading(true);
|
||||
topLoadingRef.current = true;
|
||||
run({ current: 1 });
|
||||
}, [loading, hasMoreNew, isRefreshing, items, currentTimeArr, run]);
|
||||
|
||||
// 渲染单个时间线项的函数
|
||||
const renderTimelineItem = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const item = items[index];
|
||||
if (!item) return null;
|
||||
const key = String(item.id ?? item.instanceId);
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
// 当元素被渲染时测量其实际高度
|
||||
if (el && !measuredItemsRef.current.has(key)) {
|
||||
measuredItemsRef.current.add(key);
|
||||
// 使用requestAnimationFrame确保DOM已经渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
if (el) {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
onItemResize(key, height);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
margin: '12px 0',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
|
||||
border: '1px solid #f0f0f0',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.boxShadow = '0 1px 4px rgba(0,0,0,0.05)';
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<TimelineItem
|
||||
item={item}
|
||||
handleOption={(
|
||||
item: StoryItem,
|
||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
||||
) => {
|
||||
setCurrentItem(item);
|
||||
setCurrentOption(option);
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
disableEdit={!judgePermission(detail?.permissionType, 'edit')}
|
||||
refresh={() => {
|
||||
// 刷新当前页数据
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
// 重置高度测量
|
||||
measuredItemsRef.current = new Set();
|
||||
hasShownNoMoreOldRef.current = false;
|
||||
hasShownNoMoreNewRef.current = false;
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[items, hasMoreOld, loadDirection, loading, onItemResize, run, queryDetail],
|
||||
);
|
||||
|
||||
// 使用 IntersectionObserver 监听顶部/底部哨兵实现无限滚动
|
||||
// 监听滚动事件
|
||||
useEffect(() => {
|
||||
const root = outerRef.current;
|
||||
const topEl = topSentinelRef.current;
|
||||
const bottomEl = bottomSentinelRef.current;
|
||||
if (!root || !topEl || !bottomEl) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const topObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
loadNewer();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '120px 0px 0px 0px', threshold: 0 },
|
||||
);
|
||||
|
||||
const bottomObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
loadOlder();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '0px 0px 120px 0px', threshold: 0 },
|
||||
);
|
||||
|
||||
topObserver.observe(topEl);
|
||||
bottomObserver.observe(bottomEl);
|
||||
|
||||
// 仅用于显示"回到顶部"按钮
|
||||
const handleScroll = () => {
|
||||
const { scrollTop } = root;
|
||||
setShowScrollTop(scrollTop > 200);
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
|
||||
// 显示回到顶部按钮
|
||||
setShowScrollTop(scrollTop > 300);
|
||||
|
||||
// 接近底部时加载更多
|
||||
if (scrollHeight - scrollTop - clientHeight < 200) {
|
||||
loadOlder();
|
||||
}
|
||||
};
|
||||
root.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
topObserver.disconnect();
|
||||
bottomObserver.disconnect();
|
||||
root.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [loadNewer, loadOlder]);
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [loadOlder]);
|
||||
|
||||
// 手动刷新最新数据
|
||||
const handleRefresh = () => {
|
||||
@@ -351,11 +201,62 @@ const Index = () => {
|
||||
|
||||
// 回到顶部
|
||||
const scrollToTop = () => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollToItem(0, 'start');
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
// 按日期分组items,并在每个组内按时间排序
|
||||
const groupItemsByDate = (items: StoryItem[]) => {
|
||||
const groups: { [key: string]: { dateKey: string; items: StoryItem[]; sortValue: number } } = {};
|
||||
|
||||
items.forEach(item => {
|
||||
let dateKey = '';
|
||||
let sortValue = 0;
|
||||
|
||||
if (Array.isArray(item.storyItemTime)) {
|
||||
const [year, month, day] = item.storyItemTime;
|
||||
dateKey = `${year}年${month}月${day}日`;
|
||||
sortValue = new Date(year, month - 1, day).getTime();
|
||||
} else if (item.storyItemTime) {
|
||||
const dateStr = String(item.storyItemTime);
|
||||
const datePart = dateStr.split(' ')[0];
|
||||
dateKey = datePart;
|
||||
sortValue = new Date(datePart).getTime();
|
||||
}
|
||||
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = { dateKey, items: [], sortValue };
|
||||
}
|
||||
groups[dateKey].items.push(item);
|
||||
});
|
||||
|
||||
// 对每个日期组内的项目按时间排序(从早到晚)
|
||||
Object.keys(groups).forEach(dateKey => {
|
||||
groups[dateKey].items.sort((a, b) => {
|
||||
const timeA = getTimeValue(a.storyItemTime);
|
||||
const timeB = getTimeValue(b.storyItemTime);
|
||||
return timeA - timeB;
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
// 将时间转换为可比较的数值
|
||||
const getTimeValue = (time: string | number[] | undefined): number => {
|
||||
if (!time) return 0;
|
||||
|
||||
if (Array.isArray(time)) {
|
||||
const [year, month, day, hour = 0, minute = 0, second = 0] = time;
|
||||
return new Date(year, month - 1, day, hour, minute, second).getTime();
|
||||
}
|
||||
|
||||
return new Date(String(time)).getTime();
|
||||
};
|
||||
|
||||
const groupedItems = groupItemsByDate(items);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
onBack={() => history.push('/story')}
|
||||
@@ -367,7 +268,7 @@ const Index = () => {
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => {
|
||||
setItems([]);
|
||||
setPagination({ current: 1, pageSize: 10 });
|
||||
setPagination({ current: 1, pageSize: 30 });
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
}}
|
||||
@@ -382,49 +283,68 @@ const Index = () => {
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: 'calc(100vh - 200px)',
|
||||
overflow: 'hidden',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
padding: '0 16px', // 添加一些内边距
|
||||
padding: '0 8px', // 减少内边距
|
||||
}}
|
||||
>
|
||||
{items.length > 0 ? (
|
||||
<>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<>
|
||||
<List
|
||||
ref={listRef}
|
||||
outerRef={outerRef}
|
||||
height={height}
|
||||
itemCount={items.length}
|
||||
itemSize={getItemSize}
|
||||
width={width}
|
||||
innerElementType={React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>((props, ref) => (
|
||||
<div ref={ref} {...props}>
|
||||
<div ref={topSentinelRef} style={{ height: 1 }} />
|
||||
{props.children}
|
||||
<div ref={bottomSentinelRef} style={{ height: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
>
|
||||
{renderTimelineItem}
|
||||
</List>
|
||||
<div className="load-indicator">{loading && '加载中...'}</div>
|
||||
<div className="no-more-data">{!loading && '已加载全部历史数据'}</div>
|
||||
</>
|
||||
)}
|
||||
</AutoSizer>
|
||||
{Object.values(groupedItems)
|
||||
.sort((a, b) => b.sortValue - a.sortValue) // 按日期降序排列(最新的在前)
|
||||
.map(({ dateKey, items: dateItems }) => (
|
||||
<div key={dateKey}>
|
||||
<h2 className="timeline-section-header">{dateKey}</h2>
|
||||
<div className="timeline-grid-wrapper">
|
||||
{dateItems.map((item, index) => {
|
||||
// 调试:确保每个item都有有效的数据
|
||||
if (!item || (!item.id && !item.instanceId)) {
|
||||
console.warn('发现无效的item:', item, 'at index:', index);
|
||||
return null; // 不渲染无效的item
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineGridItem
|
||||
key={item.id ?? item.instanceId}
|
||||
item={item}
|
||||
handleOption={(
|
||||
item: StoryItem,
|
||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
||||
) => {
|
||||
setCurrentItem(item);
|
||||
setCurrentOption(option);
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
onOpenDetail={(item: StoryItem) => {
|
||||
setDetailItem(item);
|
||||
setOpenDetailDrawer(true);
|
||||
}}
|
||||
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||
refresh={() => {
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
hasShownNoMoreOldRef.current = false;
|
||||
hasShownNoMoreNewRef.current = false;
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && <div className="load-indicator">加载中...</div>}
|
||||
{!loading && !hasMoreOld && <div className="no-more-data">已加载全部历史数据</div>}
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
{showScrollTop && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
zIndex: 10,
|
||||
position: 'fixed',
|
||||
bottom: 80,
|
||||
right: 24,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -482,7 +402,7 @@ const Index = () => {
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
disabled={!judgePermission(detail?.permissionType, 'edit')}
|
||||
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||
onClick={() => {
|
||||
setCurrentOption('add');
|
||||
setCurrentItem(undefined);
|
||||
@@ -503,7 +423,7 @@ const Index = () => {
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!judgePermission(detail?.permissionType, 'edit')}
|
||||
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||
type="primary"
|
||||
style={{
|
||||
right: 24,
|
||||
@@ -522,14 +442,48 @@ const Index = () => {
|
||||
setOpenAddItemModal(false);
|
||||
// 添加新项后刷新数据
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
// 重置高度测量
|
||||
measuredItemsRef.current = new Set();
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
}}
|
||||
storyId={lineId}
|
||||
/>
|
||||
|
||||
{/* 详情抽屉 - 在外层管理,不影响网格布局 */}
|
||||
{detailItem && (
|
||||
<TimelineItemDrawer
|
||||
storyItem={detailItem}
|
||||
open={openDetailDrawer}
|
||||
setOpen={setOpenDetailDrawer}
|
||||
handleDelete={async () => {
|
||||
// 这里需要实现删除逻辑
|
||||
try {
|
||||
if (!detailItem.instanceId) return;
|
||||
const response = await removeStoryItem(detailItem.instanceId);
|
||||
if (response.code === 200) {
|
||||
message.success('删除成功');
|
||||
setOpenDetailDrawer(false);
|
||||
// 刷新数据
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
} else {
|
||||
message.error('删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}}
|
||||
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||
handOption={(item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => {
|
||||
setCurrentItem(item);
|
||||
setCurrentOption(option);
|
||||
setOpenDetailDrawer(false);
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
25
src/types/react-window.d.ts
vendored
25
src/types/react-window.d.ts
vendored
@@ -1,2 +1,25 @@
|
||||
declare module 'react-window';
|
||||
declare module 'react-window' {
|
||||
import * as React from 'react';
|
||||
|
||||
export interface ListChildComponentProps {
|
||||
index: number;
|
||||
style: React.CSSProperties;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface VariableSizeListProps {
|
||||
children: React.ComponentType<ListChildComponentProps>;
|
||||
height: number;
|
||||
itemCount: number;
|
||||
itemSize: (index: number) => number;
|
||||
width: number | string;
|
||||
ref?: React.Ref<any>;
|
||||
outerRef?: React.Ref<any>;
|
||||
innerElementType?: React.ElementType;
|
||||
}
|
||||
|
||||
export class VariableSizeList extends React.Component<VariableSizeListProps> {
|
||||
scrollToItem(index: number, align?: 'start' | 'center' | 'end' | 'smart'): void;
|
||||
resetAfterIndex(index: number, shouldForceUpdate?: boolean): void;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user