增加用户中心、用户注册登录

This commit is contained in:
jiangh277
2025-12-26 15:12:49 +08:00
parent 1eb1dafe1e
commit 07e011febd
43 changed files with 2006 additions and 611 deletions

View File

@@ -1,6 +1,6 @@
// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
import { addStoryItem } from '@/pages/story/service';
import { addStoryItem, updateStoryItem } from '@/pages/story/service';
import { getImagesList } from '@/services/file/api'; // 引入获取图库图片的API
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
import { useRequest } from '@umijs/max';
@@ -102,7 +102,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
}
}, [visible, activeTab, searchKeyword]);
const { run: submitItem, loading } = useRequest((newItem) => addStoryItem(newItem), {
const { run: submitItem, loading } = useRequest((newItem) => option.includes('edit') ? updateStoryItem(newItem) : addStoryItem(newItem), {
manual: true,
onSuccess: (data) => {
console.log(data);
@@ -393,4 +393,4 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
);
};
export default AddTimeLineItemModal;
export default AddTimeLineItemModal;

View File

@@ -0,0 +1,69 @@
// 支持授权故事给其他用户,包含查看、新增、增删、管理员权限,查询当前好友列表,选择权限授权
import { queryFriendList } from '@/pages/account/center/service';
import { useRequest } from '@umijs/max';
import { Form, message, Modal, Select } from 'antd';
import { StoryType } from '../data';
import { values } from 'lodash';
import { authorizeStoryPermission } from '../service';
const AuthorizeStoryModal = ({
open,
current,
handleOk,
}: {
open: boolean;
current: Partial<StoryType> | undefined;
handleOk: (flag: boolean) => void;
}) => {
const { data: friends, loading } = useRequest(() => queryFriendList());
const [form] = Form.useForm();
const onFinish = async (values: {userId: string, permissionType: number}) => {
console.log(current, values);
if (!values.userId || !values.permissionType || !current?.instanceId) return;
let params = {userId: values.userId, permissionType: values.permissionType, storyInstanceId: current?.instanceId ?? '11'};
const res = await authorizeStoryPermission(params);
console.log(res);
if (res.code === 200) {
handleOk(true);
}
}
return (
<Modal title="授权故事" open={open} onCancel={() => handleOk(false)} onOk={() => onFinish(form.getFieldsValue())}>
{/* 请选择需要授权的好友 */}
<Form onFinish={onFinish} form={form}>
<Form.Item name={'userId'} label="授权对象">
<Select
// mode="multiple"
>
{friends?.map((item) => {
return (
<Select.Option title={item.username} key={item.userId}>
{item.username}
</Select.Option>
);
})}
</Select>
</Form.Item>
<Form.Item name="permissionType" label="权限类型">
<Select>
<Select.Option key="2" title="查看">
</Select.Option>
<Select.Option key="3" title="增加">
</Select.Option>
<Select.Option key="4" title="增删">
</Select.Option>
<Select.Option key="5" title="管理">
</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
);
};
export default AuthorizeStoryModal;

View File

@@ -56,23 +56,29 @@ const OperationModal: FC<OperationModalProps> = (props) => {
// 强制设置裁剪尺寸为 40x40
canvas.width = 40;
canvas.height = 40;
ctx.drawImage(img, 0, 0, 40, 40);
if (ctx) {
ctx.drawImage(img, 0, 0, 40, 40);
// 生成 Base64 图像
const base64 = canvas.toDataURL('image/png');
setSelectedIcon(base64);
setIconPreview(base64);
setFileList([
{
uid: '-1',
name: 'icon.png',
status: 'done',
url: base64,
originFileObj: new File([dataURLtoBlob(base64)], 'icon.png', {
type: 'image/png',
}),
},
]);
// 生成 Base64 图像
const base64 = canvas.toDataURL('image/png');
setSelectedIcon(base64);
setIconPreview(base64);
setFileList([
{
uid: '-1',
name: 'icon.png',
status: 'done',
url: base64,
originFileObj: new File([dataURLtoBlob(base64)], 'icon.png', {
type: 'image/png',
}),
},
]);
} else {
message.error('获取 Canvas 绘图上下文失败');
return;
}
};
img.onerror = () => {
message.error('图像加载失败');
@@ -90,7 +96,14 @@ const OperationModal: FC<OperationModalProps> = (props) => {
// Base64 → Blob 转换工具函数
const dataURLtoBlob = (dataurl: string) => {
const arr = dataurl.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
if (!arr || arr.length < 2) {
throw new Error('无效的Base64数据');
}
const mimeMatch = arr[0]?.match(/:(.*?);/);
if (!mimeMatch || !mimeMatch[1]) {
throw new Error('无法解析MIME类型');
}
const mime = mimeMatch[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
@@ -248,7 +261,7 @@ const OperationModal: FC<OperationModalProps> = (props) => {
},
]);
};
img.src = iconPreview;
img.src = iconPreview ?? '';
}}
>
<Upload
@@ -294,38 +307,10 @@ const OperationModal: FC<OperationModalProps> = (props) => {
placeholder="请选择"
/>
<ProFormSelect
name="ownerId"
label="故事负责人"
rules={[
{
required: true,
message: '请选择故事负责人',
},
]}
options={[
{
label: '付晓晓',
value: 'xiao',
},
{
label: '周毛毛',
value: 'mao',
},
]}
placeholder="请选择管理员"
/>
<ProFormTextArea
name="description"
label="产品描述"
rules={[
{
message: '请输入至少五个字符的产品描述!',
min: 5,
},
]}
placeholder="请输入至少五个字符"
label="故事描述"
placeholder="可以简单描述当前故事"
/>
</>
) : (

View File

@@ -3,12 +3,26 @@ import TimelineImage from '@/components/TimelineImage';
import { StoryItem } from '@/pages/story/data';
import { DeleteOutlined, DownOutlined, EditOutlined, UpOutlined } from '@ant-design/icons';
import { useIntl, useRequest } from '@umijs/max';
import { Button, Card, message, Popconfirm } from 'antd';
import { Button, Card, message, Popconfirm, Tag, Space } from 'antd';
import React, { useState } from 'react';
import { queryStoryItemImages, removeStoryItem } from '../../service';
import TimelineItemDrawer from '../TimelineItemDrawer';
import useStyles from './index.style';
// 格式化时间数组为易读格式
const formatTimeArray = (time: string | number[] | undefined): string => {
if (!time) return '';
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
if (Array.isArray(time)) {
const [year, month, day, hour, minute, second] = time;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
}
// 如果已经是字符串格式,直接返回
return String(time);
};
const TimelineItem: React.FC<{
item: StoryItem;
handleOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void;
@@ -20,8 +34,11 @@ const TimelineItem: React.FC<{
const [showActions, setShowActions] = useState(false);
const [subItemsExpanded, setSubItemsExpanded] = useState(false);
const [openDetail, setOpenDetail] = useState(false);
const [hovered, setHovered] = useState(false);
const { data: imagesList } = useRequest(async () => {
return await queryStoryItemImages(item.instanceId);
}, {
refreshDeps: [item.instanceId]
});
const handleDelete = async () => {
try {
@@ -52,10 +69,32 @@ const TimelineItem: React.FC<{
return (
<Card
className={styles.timelineItem}
title={item.title}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
className={`${styles.timelineItem} ${hovered ? styles.timelineItemHover : ''}`}
title={
<div className={styles.timelineItemTitle}>
<div className={styles.timelineItemTitleText}>{item.title}</div>
<Space className={styles.timelineItemTags}>
{item.createName && (
<Tag color="blue" className={styles.creatorTag}>
: {item.createName}
</Tag>
)}
{item.updateName && item.updateName !== item.createName && (
<Tag color="green" className={styles.updaterTag}>
: {item.updateName}
</Tag>
)}
</Space>
</div>
}
onMouseEnter={() => {
setShowActions(true);
setHovered(true);
}}
onMouseLeave={() => {
setShowActions(false);
setHovered(false);
}}
extra={
<div className={styles.actions}>
{showActions && (
@@ -100,12 +139,14 @@ const TimelineItem: React.FC<{
)}
</div>
}
// onClick={() => onDetail(item)}
hoverable
>
<div className={styles.content}>
<div className={styles.date} onClick={() => setOpenDetail(true)}>
{item.storyItemTime} {item.location ? `创建于${item.location}` : ''}
<Space size="small" className={styles.dateInfo}>
<span className={styles.time}>{formatTimeArray(item.storyItemTime)}</span>
{item.location && <span className={styles.location}>📍 {item.location}</span>}
</Space>
</div>
<div className={styles.description} onClick={() => setOpenDetail(true)}>
{displayedDescription}
@@ -125,13 +166,13 @@ const TimelineItem: React.FC<{
</div>
{imagesList && imagesList.length > 0 && (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginBottom: '20px' }}>
<div className={styles.timelineItemImages}>
{imagesList.map((imageInstanceId, index) => (
<TimelineImage
key={imageInstanceId + index}
title={imageInstanceId}
imageInstanceId={imageInstanceId}
style={{ maxWidth: '100%', height: 'auto' }} // 添加响应式样式
className={styles.timelineImage}
/>
))}
</div>
@@ -156,7 +197,7 @@ const TimelineItem: React.FC<{
{item.subItems.map((subItem) => (
<div key={subItem.id} className={styles.subItem}>
<div className={styles.subItemDate}>
{item.storyItemTime} {item.location ? `创建于${item.location}` : ''}
{formatTimeArray(item.storyItemTime)} {item.location ? `创建于${item.location}` : ''}
</div>
<div className={styles.subItemContent}>{subItem.description}</div>
</div>
@@ -177,4 +218,4 @@ const TimelineItem: React.FC<{
);
};
export default TimelineItem;
export default TimelineItem;

View File

@@ -9,9 +9,6 @@ const useStyles = createStyles(({ token }) => {
borderRadius: token.borderRadius,
transition: 'all 0.3s',
cursor: 'pointer',
'&:hover': {
boxShadow: token.boxShadowSecondary,
},
position: 'relative',
padding: '20px',
maxWidth: '100%',
@@ -24,6 +21,9 @@ const useStyles = createStyles(({ token }) => {
padding: '10px',
},
},
timelineItemHover: {
boxShadow: token.boxShadowSecondary,
},
actions: {
display: 'flex',
gap: '8px',
@@ -31,6 +31,28 @@ const useStyles = createStyles(({ token }) => {
height: '24px',
width: '120px',
},
timelineItemTitle: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
},
timelineItemTitleText: {
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
timelineItemTags: {
flexShrink: 0,
marginLeft: '16px',
},
creatorTag: {
fontSize: '12px',
},
updaterTag: {
fontSize: '12px',
},
content: {
padding: '10px 0',
},
@@ -52,6 +74,20 @@ const useStyles = createStyles(({ token }) => {
marginBottom: '10px',
fontWeight: 'bold',
},
dateInfo: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
time: {
color: token.colorTextSecondary,
},
location: {
color: token.colorTextTertiary,
display: 'flex',
alignItems: 'center',
gap: '4px',
},
description: {
fontSize: '16px',
lineHeight: '1.6',
@@ -97,10 +133,14 @@ const useStyles = createStyles(({ token }) => {
color: token.colorText,
},
timelineItemImages: {
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', // 减小最小宽度到100px
gap: '8px', // 减少间距
marginBottom: '20px',
maxWidth: '100%', // 确保容器不会超出父元素宽度
[`@media (max-width: 768px)`]: {
gridTemplateColumns: 'repeat(auto-fit, minmax(60px, 1fr))', // 在移动设备上减小最小宽度到60px
},
},
timelineImage: {
maxWidth: '100%',
@@ -116,4 +156,4 @@ const useStyles = createStyles(({ token }) => {
};
});
export default useStyles;
export default useStyles;

View File

@@ -4,9 +4,23 @@ 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 } from 'antd';
import { Button, Divider, Drawer, Popconfirm, Space, Tag } from 'antd';
import React, { useEffect } from 'react';
// 格式化时间数组为易读格式
const formatTimeArray = (time: string | number[] | undefined): string => {
if (!time) return '';
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
if (Array.isArray(time)) {
const [year, month, day, hour, minute, second] = time;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
}
// 如果已经是字符串格式,直接返回
return String(time);
};
interface Props {
storyItem: StoryItem;
open: boolean;
@@ -39,9 +53,11 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
};
// 格式化日期显示
const formatDate = (dateString: string) => {
const formatDate = (dateString: string | number[] | undefined) => {
if (!dateString) return '';
const date = new Date(dateString);
const formattedTime = formatTimeArray(dateString);
const date = new Date(formattedTime);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
@@ -63,9 +79,23 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
zIndex={1000}
title={
<div>
<h2 style={{ margin: 0 }}>{storyItem.title}</h2>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>{storyItem.title}</h2>
<div style={{ display: 'flex', gap: '8px' }}>
{storyItem.createName && (
<Tag color="blue">
: {storyItem.createName}
</Tag>
)}
{storyItem.updateName && storyItem.updateName !== storyItem.createName && (
<Tag color="green">
: {storyItem.updateName}
</Tag>
)}
</div>
</div>
<div style={{ fontSize: '14px', color: '#888', marginTop: '4px' }}>
{storyItem.storyItemTime} {storyItem.location ? `${storyItem.location}` : ''}
{formatTimeArray(storyItem.storyItemTime)} {storyItem.location ? `${storyItem.location}` : ''}
</div>
</div>
}
@@ -158,9 +188,13 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
<div style={{ color: '#888', marginBottom: '4px' }}></div>
<div style={{ fontSize: '16px' }}>{formatDate(storyItem.updateTime)}</div>
</div>
<div style={{ flex: '1', minWidth: '200px' }}>
<div style={{ color: '#888', marginBottom: '4px' }}></div>
<div style={{ fontSize: '16px' }}>{storyItem.createName || '系统用户'}</div>
</div>
<div style={{ flex: '1', minWidth: '200px' }}>
<div style={{ color: '#888', marginBottom: '4px' }}></div>
<div style={{ fontSize: '16px' }}></div>
<div style={{ fontSize: '16px' }}>{storyItem.updateName || storyItem.createName || '系统用户'}</div>
</div>
</div>
</div>
@@ -170,4 +204,4 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
);
};
export default TimelineItemDrawer;
export default TimelineItemDrawer;

View File

@@ -66,6 +66,14 @@ export interface StoryItem {
images?: string[]; // 多张图片
subItems?: StoryItem[];
isRoot: number;
updateId?: string;
updateName?: string;
createId?: string;
createName?: string;
}
export interface StoryItemTimeQueryParams {
beforeTime?: string;
afterTime?: string;
}
export interface AddStoryItem extends StoryItem{
file: FormData;
@@ -81,3 +89,5 @@ export interface TimelineEvent {
images?: string[]; // 多张图片
subItems?: TimelineEvent[];
}
export type PermissionType = ''

View File

@@ -1,22 +1,39 @@
// src/pages/story/detail.tsx
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
import TimelineItem from '@/pages/story/components/TimelineItem/TimelineItem';
import { StoryItem } from '@/pages/story/data';
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
import { queryStoryDetail, queryStoryItem } 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 { FloatButton, Spin, Empty, Button } from 'antd';
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 { SyncOutlined } from '@ant-design/icons';
import './index.css';
// 格式化时间数组为易读格式
const formatTimeArray = (time: string | number[] | undefined): string => {
if (!time) return '';
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
if (Array.isArray(time)) {
const [year, month, day, hour, minute, second] = time;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
}
// 如果已经是字符串格式,直接返回
return String(time);
};
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); // 是否有更老的数据
@@ -33,10 +50,23 @@ const Index = () => {
const measuredItemsRef = useRef<Set<string>>(new Set());
const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
const [showScrollTop, setShowScrollTop] = useState(false); // 是否显示回到顶部按钮
const [currentTimeArr, setCurrentTimeArr] = useState<[string | undefined, string | undefined]>([
undefined,
undefined,
]);
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 };
const { data: response, run } = useRequest(
() => {
return queryStoryItem({ storyInstanceId: lineId, ...pagination });
(params?: QueryParams) => {
return queryStoryItem({ storyInstanceId: lineId, pageSize: pagination.pageSize, ...params });
},
{
manual: true,
@@ -53,100 +83,100 @@ const Index = () => {
// 初始化加载数据
useEffect(() => {
setItems([]);
setPagination({ current: 1, pageSize: 10 });
setHasMoreOld(true);
setHasMoreNew(true);
// 重置高度缓存
setItemSizes({});
measuredItemsRef.current = new Set();
run();
setLoadDirection('init');
setLoading(true);
run({ current: 1 });
}, [lineId]);
// 处理响应数据
useEffect(() => {
if (!response) return;
const fetched = response.list || [];
const pageSize = pagination.pageSize;
const noMore = !(fetched.length === pageSize);
if (pagination.current === 1) {
// 首页数据
setItems(response.list || []);
} else if (pagination.current > 1) {
// 追加更老的数据
setItems(prev => [...prev, ...(response.list || [])]);
} else if (pagination.current < 1) {
// 在前面插入更新的数据
setItems(prev => [...(response.list || []), ...prev]);
// 保持滚动位置
setTimeout(() => {
if (listRef.current && response.list) {
listRef.current.scrollToItem(response.list.length, 'start');
}
}, 0);
// 若无新数据则避免触发列表重绘,只更新加载状态
if (!fetched.length) {
if (loadDirection === 'older') {
setHasMoreOld(false);
} else if (loadDirection === 'newer') {
setHasMoreNew(false);
topLoadingRef.current = false;
}
setLoading(false);
setIsRefreshing(false);
setLoadDirection('init');
return;
}
// 检查是否还有更多数据
if (pagination.current >= 1) {
// 检查是否有更老的数据
setHasMoreOld(response.list && response.list.length === pagination.pageSize);
} else if (pagination.current < 1) {
// 检查是否有更新的数据
setHasMoreNew(response.list && response.list.length === pagination.pageSize);
if (loadDirection === 'older') {
setItems((prev) => {
const next = [...prev, ...fetched];
setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]);
return next;
});
setHasMoreOld(!noMore);
if (noMore && !hasShownNoMoreOldRef.current) {
hasShownNoMoreOldRef.current = true;
message.info('没有更多历史内容了');
}
} else if (loadDirection === 'newer') {
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);
if (noMore && !hasShownNoMoreNewRef.current) {
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]);
setHasMoreOld(!noMore);
setHasMoreNew(true);
}
setLoading(false);
setIsRefreshing(false);
}, [response, pagination]);
// 滚动到底部加载更老的数据
const loadOlder = useCallback(() => {
if (loading || !hasMoreOld || pagination.current < 1) return;
setLoading(true);
setPagination(prev => ({
...prev,
current: prev.current + 1
}));
}, [loading, hasMoreOld, pagination]);
// 滚动到顶部加载更新的数据
const loadNewer = useCallback(() => {
if (loading || !hasMoreNew || pagination.current > 1 || isRefreshing) return;
setIsRefreshing(true);
setPagination(prev => ({
...prev,
current: prev.current - 1
}));
}, [loading, hasMoreNew, pagination, isRefreshing]);
// 当分页变化时重新请求数据
useEffect(() => {
if (pagination.current !== 1) {
run();
}
}, [pagination, run]);
setLoadDirection('init');
}, [response, loadDirection, pagination.pageSize]);
// 获取item高度的函数
const getItemSize = useCallback((index: number) => {
const getItemSize = useCallback(
(index: number) => {
const item = items[index];
if (!item) return 300; // 默认高度
if (!item) return 400; // 默认高度
const key = String(item.id ?? item.instanceId);
// 如果已经测量过该item的高度则使用缓存的值
if (itemSizes[item.id]) {
return itemSizes[item.id];
if (itemSizes[key]) {
return itemSizes[key];
}
// 返回默认高度
return 300;
}, [items, itemSizes]);
return 400;
},
[items, itemSizes, loadDirection, loading],
);
// 当item尺寸发生变化时调用
const onItemResize = useCallback((itemId: string, height: number) => {
const onItemResize = useCallback(
(itemId: string, height: number) => {
// 只有当高度发生变化时才更新
if (itemSizes[itemId] !== height) {
setItemSizes(prev => ({
setItemSizes((prev) => ({
...prev,
[itemId]: height
[itemId]: height,
}));
// 通知List组件重新计算尺寸
@@ -154,47 +184,85 @@ const Index = () => {
listRef.current.resetAfterIndex(0);
}
}
}, [itemSizes]);
},
[itemSizes],
);
// 滚动到底部加载更老的数据
const loadOlder = useCallback(() => {
if (loading || !hasMoreOld) {
if (!hasMoreOld && !loading) {
message.info('没有更多历史内容了');
}
return;
}
const beforeTime = items[items.length - 1]?.storyItemTime || currentTimeArr[1];
if (!beforeTime) return;
const nextPage = pagination.current + 1;
setPagination((prev) => ({ ...prev, current: nextPage }));
setLoadDirection('older');
setLoading(true);
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('没有更多更新内容了');
}
return;
}
const afterTime = items[0]?.storyItemTime || currentTimeArr[0];
if (!afterTime) return;
setPagination((prev) => ({ ...prev, current: 1 }));
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 showOlderLoading = index === items.length && hasMoreOld && pagination.current >= 1;
const showNewerLoading = index === 0 && hasMoreNew && pagination.current < 1 && isRefreshing;
if (showOlderLoading || showNewerLoading) {
return (
<div style={style}>
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin />
<div style={{ marginTop: 8 }}>
{showNewerLoading ? '正在加载更新的内容...' : '正在加载更多内容...'}
</div>
</div>
</div>
);
}
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(item.id)) {
measuredItemsRef.current.add(item.id);
if (el && !measuredItemsRef.current.has(key)) {
measuredItemsRef.current.add(key);
// 使用requestAnimationFrame确保DOM已经渲染完成
requestAnimationFrame(() => {
if (el) {
const height = el.getBoundingClientRect().height;
onItemResize(item.id, 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}
@@ -208,10 +276,13 @@ const Index = () => {
}}
refresh={() => {
// 刷新当前页数据
setPagination(prev => ({ ...prev, current: 1 }));
setPagination((prev) => ({ ...prev, current: 1 }));
// 重置高度测量
measuredItemsRef.current = new Set();
run();
hasShownNoMoreOldRef.current = false;
hasShownNoMoreNewRef.current = false;
setLoadDirection('refresh');
run({ current: 1 });
queryDetail();
}}
/>
@@ -219,34 +290,61 @@ const Index = () => {
</div>
);
},
[items, hasMoreOld, hasMoreNew, pagination, isRefreshing, onItemResize, run, queryDetail],
[items, hasMoreOld, loadDirection, loading, onItemResize, run, queryDetail],
);
// 处理滚动事件
const handleItemsRendered = useCallback(({ visibleStartIndex, visibleStopIndex }) => {
// 当可视区域接近列表顶部时加载更新的数据
if (visibleStartIndex <= 3 && hasMoreNew && !isRefreshing && pagination.current >= 1) {
loadNewer();
}
// 使用 IntersectionObserver 监听顶部/底部哨兵实现无限滚动
useEffect(() => {
const root = outerRef.current;
const topEl = topSentinelRef.current;
const bottomEl = bottomSentinelRef.current;
if (!root || !topEl || !bottomEl) return;
// 当可视区域接近列表底部时加载更老的数据
if (visibleStopIndex >= items.length - 3 && hasMoreOld && !loading && pagination.current >= 1) {
loadOlder();
}
const topObserver = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
loadNewer();
}
},
{ root, rootMargin: '120px 0px 0px 0px', threshold: 0 },
);
// 控制回到顶部按钮的显示
setShowScrollTop(visibleStartIndex > 5);
}, [hasMoreNew, hasMoreOld, isRefreshing, loading, items.length, pagination, loadNewer, loadOlder]);
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);
};
root.addEventListener('scroll', handleScroll);
return () => {
topObserver.disconnect();
bottomObserver.disconnect();
root.removeEventListener('scroll', handleScroll);
};
}, [loadNewer, loadOlder]);
// 手动刷新最新数据
const handleRefresh = () => {
if (isRefreshing) return;
setIsRefreshing(true);
setPagination(prev => ({
...prev,
current: 0 // 使用0作为刷新标识
}));
setLoadDirection('refresh');
setPagination((prev) => ({ ...prev, current: 1 }));
hasShownNoMoreOldRef.current = false;
hasShownNoMoreNewRef.current = false;
run({ current: 1 });
};
// 回到顶部
@@ -256,34 +354,6 @@ const Index = () => {
}
};
// 监听滚动事件,动态显示/隐藏提示信息
useEffect(() => {
const handleScroll = () => {
if (containerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
// 判断是否在顶部
const isTop = scrollTop === 0;
// 判断是否在底部
const isBottom = scrollTop + clientHeight >= scrollHeight;
setHasMoreNew(isTop && hasMoreNew); // 更新顶部提示的显示状态
setHasMoreOld(isBottom && hasMoreOld); // 更新底部提示的显示状态
}
};
const timelineContainer = containerRef.current;
if (timelineContainer) {
timelineContainer.addEventListener('scroll', handleScroll);
}
return () => {
if (timelineContainer) {
timelineContainer.removeEventListener('scroll', handleScroll);
}
};
}, [hasMoreNew, hasMoreOld]);
return (
<PageContainer
onBack={() => history.push('/story')}
@@ -293,7 +363,12 @@ const Index = () => {
extra={
<Button
icon={<SyncOutlined />}
onClick={handleRefresh}
onClick={() => {
setItems([]);
setPagination({ current: 1, pageSize: 10 });
setLoadDirection('refresh');
run({ current: 1 });
}}
loading={isRefreshing}
>
@@ -306,68 +381,50 @@ const Index = () => {
style={{
height: 'calc(100vh - 200px)',
overflow: 'hidden',
position: 'relative'
position: 'relative',
padding: '0 16px', // 添加一些内边距
}}
>
{items.length > 0 ? (
<>
{/* 顶部提示信息 */}
{!hasMoreNew && pagination.current <= 1 && (
<div style={{
textAlign: 'center',
padding: '12px',
color: '#999',
fontSize: '14px',
position: 'sticky',
top: 0,
backgroundColor: '#fff',
zIndex: 10,
borderBottom: '1px solid #f0f0f0'
}}>
</div>
)}
<AutoSizer>
{({ height, width }) => (
<>
<List
ref={listRef}
height={height - (hasMoreNew && pagination.current <= 1 ? 40 : 0) - (hasMoreOld && pagination.current >= 1 ? 40 : 0)}
itemCount={items.length + (hasMoreOld && pagination.current >= 1 ? 1 : 0) + (hasMoreNew && pagination.current < 1 && isRefreshing ? 1 : 0)}
outerRef={outerRef}
height={height}
itemCount={items.length}
itemSize={getItemSize}
width={width}
onItemsRendered={handleItemsRendered}
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>
{/* 底部提示信息 */}
{!hasMoreOld && pagination.current >= 1 && (
<div style={{
textAlign: 'center',
padding: '12px',
color: '#999',
fontSize: '14px',
position: 'sticky',
bottom: 0,
backgroundColor: '#fff',
zIndex: 10,
borderTop: '1px solid #f0f0f0'
}}>
</div>
)}
{/* 回到顶部按钮 */}
{showScrollTop && (
<div style={{
<div
style={{
position: 'absolute',
bottom: 20,
right: 20,
zIndex: 10
}}>
zIndex: 10,
}}
>
<Button
type="primary"
shape="circle"
@@ -378,64 +435,93 @@ const Index = () => {
)}
</>
) : (
<div style={{
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
textAlign: 'center'
}}>
textAlign: 'center',
}}
>
{loading ? (
<>
<Spin size="large" />
<div style={{ marginTop: 16 }}>线...</div>
</>
) : (
<>
<Empty
description="暂无时间线数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
<Button
type="primary"
style={{ marginTop: 16 }}
onClick={() => {
setCurrentOption('add');
setCurrentItem();
setOpenAddItemModal(true);
<div
style={{
marginTop: 16,
fontSize: '16px',
color: '#666',
}}
>
</Button>
线...
</div>
</>
) : (
<>
<Empty
description="暂无时间线数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
imageStyle={{
height: 60,
}}
>
<div
style={{
fontSize: '16px',
color: '#999',
marginBottom: '16px',
}}
>
</div>
</Empty>
<Button
type="primary"
size="large"
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
>
</Button>
</>
)}
</div>
)}
</div>
<FloatButton
onClick={() => {
setCurrentOption('add');
setCurrentItem();
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
icon={<PlusOutlined />}
type="primary"
style={{
right: 24,
bottom: 24,
}}
/>
<AddTimeLineItemModal
visible={openAddItemModal}
initialValues={currentItem}
option={currentOption}
option={currentOption || 'add'}
onCancel={() => {
setOpenAddItemModal(false);
}}
onOk={() => {
setOpenAddItemModal(false);
// 添加新项后刷新数据
setPagination(prev => ({ ...prev, current: 1 }));
setPagination((prev) => ({ ...prev, current: 1 }));
// 重置高度测量
measuredItemsRef.current = new Set();
run();
setLoadDirection('refresh');
run({ current: 1 });
queryDetail();
}}
storyId={lineId}
@@ -444,4 +530,4 @@ const Index = () => {
);
};
export default Index;
export default Index;

View File

@@ -8,6 +8,7 @@ import OperationModal from './components/OperationModal';
import type { StoryType } from './data.d';
import { addStory, deleteStory, queryTimelineList, updateStory } from './service';
import useStyles from './style.style';
import AuthorizeStoryModal from './components/AuthorizeStoryModal';
const { Search } = Input;
@@ -47,6 +48,7 @@ export const BasicList: FC = () => {
const { styles } = useStyles();
const [done, setDone] = useState<boolean>(false);
const [open, setVisible] = useState<boolean>(false);
const [authorizeModelOpen, setAuthorizeModelOpen] = useState<boolean>(false);
const [current, setCurrent] = useState<Partial<StoryType> | undefined>(undefined);
const {
data: listData,
@@ -195,7 +197,26 @@ export const BasicList: FC = () => {
>
</a>,
<MoreBtn key="more" item={item} />,
// 增加授权操作,可以授权给其他用户
<a
key="authorize"
onClick={(e) => {
e.preventDefault();
setCurrent(item);
setAuthorizeModelOpen(true);
}}
>
</a>,
<a
key="delete"
onClick={(e) => {
e.preventDefault();
deleteItem(item.instanceId ?? '');
}}
>
</a>,
]}
>
<List.Item.Meta
@@ -225,6 +246,17 @@ export const BasicList: FC = () => {
onDone={handleDone}
onSubmit={handleSubmit}
/>
<AuthorizeStoryModal
open={authorizeModelOpen}
current={current}
handleOk={(flag) => {
if(flag) {
run();
}
setAuthorizeModelOpen(false);
setCurrent({});
}}
/>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { request } from '@umijs/max';
import { StoryItem, StoryType } from './data.d';
import {StoryItem, StoryItemTimeQueryParams, StoryType} from './data.d';
import {CommonListResponse, CommonResponse} from "@/types/common";
type ParamsType = {
@@ -8,12 +8,12 @@ type ParamsType = {
storyName?: string;
pageSize?: number;
current?: number;
} & Partial<StoryType> & Partial<StoryItem>;
} & Partial<StoryType> & Partial<StoryItem> & Partial<StoryItemTimeQueryParams>;
export async function queryTimelineList(
params: ParamsType,
): Promise<{ data: StoryType[] }> {
return await request('/story/owner/test11', {
return await request('/story/list', {
params,
});
}
@@ -56,6 +56,14 @@ export async function addStoryItem(params: FormData): Promise<any> {
getResponse: true,
});
}
export async function updateStoryItem(params: FormData): Promise<any> {
return request(`/story/item`, {
method: 'PUT',
data: params,
requestType: 'form',
getResponse: true,
});
}
export async function queryStoryItem(params: ParamsType): Promise<{ data: CommonListResponse<StoryItem> }> {
return request(`/story/item/list`, {
@@ -93,3 +101,10 @@ export async function fetchImage(imageInstanceId: string): Promise<any> {
getResponse: true,
});
}
export async function authorizeStoryPermission(params: {userId: string, storyInstanceId: string, permissionType: number}) {
return request('/story/permission/authorize', {
method: 'POST',
data: params,
});
}