Compare commits

...

3 Commits

Author SHA1 Message Date
336208b7ce Merge branch 'master' of http://59.80.22.43:30000/admin/timeline-frontend
All checks were successful
test/timeline-frontend/pipeline/head This commit looks good
2026-01-20 09:43:14 +08:00
638a5e5697 时间线详情展示重构 2026-01-19 18:09:37 +08:00
7b91848dcd 时间线详情展示重构 2026-01-19 18:08:55 +08:00
11 changed files with 18137 additions and 13121 deletions

29491
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -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>
),

View 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**:确保按时间顺序排列

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

View File

@@ -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,8 +157,7 @@ const TimelineItem: React.FC<{
)}
</div>
{imagesList && imagesList.length > 0 && (
<>
<div className={styles.timelineItemImages}>
<div className="timeline-images-grid">
{imagesList.map((imageInstanceId, index) => (
<TimelineImage
key={imageInstanceId + index}
@@ -177,7 +167,6 @@ const TimelineItem: React.FC<{
/>
))}
</div>
</>
)}
{item.subItems && item.subItems.length > 0 && (
<div className={styles.subItems}>

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

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

View File

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

View File

@@ -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 handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const bottomObserver = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
// 显示回到顶部按钮
setShowScrollTop(scrollTop > 300);
// 接近底部时加载更多
if (scrollHeight - scrollTop - clientHeight < 200) {
loadOlder();
}
},
{ root, rootMargin: '0px 0px 120px 0px', threshold: 0 },
);
topObserver.observe(topEl);
bottomObserver.observe(bottomEl);
// 仅用于显示"回到顶部"按钮
const handleScroll = () => {
const { scrollTop } = root;
setShowScrollTop(scrollTop > 200);
};
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,48 +283,67 @@ 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 }} />
{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>
))}
>
{renderTimelineItem}
</List>
<div className="load-indicator">{loading && '加载中...'}</div>
<div className="no-more-data">{!loading && '已加载全部历史数据'}</div>
</>
)}
</AutoSizer>
{loading && <div className="load-indicator">...</div>}
{!loading && !hasMoreOld && <div className="no-more-data"></div>}
{/* 回到顶部按钮 */}
{showScrollTop && (
<div
style={{
position: 'absolute',
bottom: 20,
right: 20,
position: 'fixed',
bottom: 80,
right: 24,
zIndex: 10,
}}
>
@@ -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>
);
};

View File

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