post scroll

This commit is contained in:
2025-03-11 13:05:51 +08:00
parent 609dfec44e
commit 9d89eb4290
3 changed files with 328 additions and 130 deletions

View File

@@ -1511,35 +1511,35 @@ analyticsRouter.get('/project/:id/conversion-funnel', async (c) => {
.eq('id', projectId) .eq('id', projectId)
.single(); .single();
// // 如果找不到项目或发生错误,返回模拟数据 // 如果找不到项目或发生错误,返回模拟数据
// if (projectError) { if (projectError) {
// console.log(`项目未找到或数据库错误返回模拟数据。项目ID: ${projectId}, 错误: ${projectError.message}`); console.log(`项目未找到或数据库错误返回模拟数据。项目ID: ${projectId}, 错误: ${projectError.message}`);
// // 生成模拟的漏斗数据 // 生成模拟的漏斗数据
// const mockFunnelData = [ const mockFunnelData = [
// { stage: 'Awareness', count: 100, rate: 100 }, { stage: 'Awareness', count: 100, rate: 100 },
// { stage: 'Interest', count: 75, rate: 75 }, { stage: 'Interest', count: 75, rate: 75 },
// { stage: 'Consideration', count: 50, rate: 50 }, { stage: 'Consideration', count: 50, rate: 50 },
// { stage: 'Intent', count: 30, rate: 30 }, { stage: 'Intent', count: 30, rate: 30 },
// { stage: 'Evaluation', count: 20, rate: 20 }, { stage: 'Evaluation', count: 20, rate: 20 },
// { stage: 'Purchase', count: 10, rate: 10 } { stage: 'Purchase', count: 10, rate: 10 }
// ]; ];
// return c.json({ return c.json({
// project: { project: {
// id: projectId, id: projectId,
// name: `模拟项目 (ID: ${projectId})` name: `模拟项目 (ID: ${projectId})`
// }, },
// timeframe, timeframe,
// funnel_data: mockFunnelData, funnel_data: mockFunnelData,
// metrics: { metrics: {
// total_influencers: 100, total_influencers: 100,
// conversion_rate: 10, conversion_rate: 10,
// avg_stage_dropoff: 18 avg_stage_dropoff: 18
// }, },
// is_mock_data: true is_mock_data: true
// }); });
// } }
// 获取项目关联的网红及其详细信息 // 获取项目关联的网红及其详细信息
const { data: projectInfluencers, error: influencersError } = await supabase const { data: projectInfluencers, error: influencersError } = await supabase

View File

@@ -53,7 +53,7 @@ const AppContent = () => {
// Render main app layout when authenticated // Render main app layout when authenticated
const renderAppLayout = () => { const renderAppLayout = () => {
return ( return (
<div className="flex h-screen bg-gray-100 overflow-hidden"> <div className="flex h-screen bg-gray-100">
<Sidebar <Sidebar
activePage={activePage} activePage={activePage}
onPageChange={(page) => { onPageChange={(page) => {
@@ -63,10 +63,11 @@ const AppContent = () => {
isOpen={sidebarOpen} isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)} onClose={() => setSidebarOpen(false)}
/> />
<div className="flex flex-col flex-1 overflow-hidden"> <div className="flex flex-col flex-1 overflow-auto">
<Header <Header
onMenuClick={() => setSidebarOpen(!sidebarOpen)} onMenuClick={() => setSidebarOpen(!sidebarOpen)}
/> />
<main className="flex-1 overflow-auto p-4">
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/comments" element={<CommentList />} /> <Route path="/comments" element={<CommentList />} />
@@ -74,6 +75,7 @@ const AppContent = () => {
<Route path="/analytics" element={<Analytics />} /> <Route path="/analytics" element={<Analytics />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</main>
</div> </div>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Card, Card,
@@ -11,7 +11,9 @@ import {
Typography, Typography,
Select, Select,
Drawer, Drawer,
Form Form,
Empty,
Spin
} from 'antd'; } from 'antd';
import { import {
UserOutlined, UserOutlined,
@@ -26,6 +28,7 @@ import {
MessageOutlined MessageOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { format } from 'date-fns'; import { format } from 'date-fns';
import supabase from '../utils/supabase';
// API response type definition based on backend structure // API response type definition based on backend structure
interface ApiPost { interface ApiPost {
@@ -87,64 +90,95 @@ const PostList: React.FC<PostListProps> = ({ influencerId, projectId }) => {
const [contentTypeFilter, setContentTypeFilter] = useState<string>('all'); const [contentTypeFilter, setContentTypeFilter] = useState<string>('all');
const [showFilters, setShowFilters] = useState<boolean>(false); const [showFilters, setShowFilters] = useState<boolean>(false);
const [totalPosts, setTotalPosts] = useState<number>(0); const [totalPosts, setTotalPosts] = useState<number>(0);
const [offset, setOffset] = useState<number>(0);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
const observer = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
// Fetch posts data // Fetch posts data
const fetchPosts = async () => { const fetchPosts = async () => {
try {
setLoading(true); setLoading(true);
setError(null); setError(null);
setPosts([]);
setOffset(0);
setHasMore(true);
// 构建查询参数 try {
const queryParams = new URLSearchParams(); // Build query parameters
const params = new URLSearchParams();
const pageSize = 20;
params.append('limit', pageSize.toString());
params.append('offset', '0');
params.append('sort', 'published_at');
params.append('order', 'desc');
if (platformFilter && platformFilter !== 'all') {
params.append('platform', platformFilter);
}
// 添加过滤条件
if (influencerId) { if (influencerId) {
queryParams.append('influencer_id', influencerId); params.append('influencer_id', influencerId);
} }
if (projectId) { if (projectId) {
queryParams.append('project_id', projectId); params.append('project_id', projectId);
} }
if (platformFilter !== 'all') { // Get auth token from Supabase
queryParams.append('platform', platformFilter); const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;
if (!token) {
console.error('Authentication token not found');
setError('Authentication required. Please log in again.');
setLoading(false);
// Fall back to mock data if no token
const mockData = getMockPosts();
setPosts(mockData);
setTotalPosts(mockData.length);
setHasMore(false);
return;
} }
// 添加分页和排序 // Make API request
queryParams.append('limit', '20'); const response = await fetch(`http://localhost:4000/api/posts?${params.toString()}`, {
queryParams.append('offset', '0'); method: 'GET',
queryParams.append('sort', 'published_at');
queryParams.append('order', 'desc');
// 添加认证头
const authToken = 'eyJhbGciOiJIUzI1NiIsImtpZCI6Inl3blNGYnRBOGtBUnl4UmUiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3h0cWhsdXpvcm5hemxta29udWNyLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiI1YjQzMThiZi0yMWE4LTQ3YWMtOGJmYS0yYThmOGVmOWMwZmIiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzQxNjI3ODkyLCJpYXQiOjE3NDE2MjQyOTIsImVtYWlsIjoidml0YWxpdHltYWlsZ0BnbWFpbC5jb20iLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImVtYWlsIiwicHJvdmlkZXJzIjpbImVtYWlsIl19LCJ1c2VyX21ldGFkYXRhIjp7ImVtYWlsX3ZlcmlmaWVkIjp0cnVlfSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc0MTYyNDI5Mn1dLCJzZXNzaW9uX2lkIjoiODlmYjg0YzktZmEzYy00YmVlLTk0MDQtNjI1MjE0OGIyMzVlIiwiaXNfYW5vbnltb3VzIjpmYWxzZX0.VuUX2yhqN-FZseKL8fQG91i1cohfRqW2m1Z8CIWhZuk';
// 发送API请求
const response = await fetch(`http://localhost:4000/api/posts?${queryParams.toString()}`, {
headers: { headers: {
'accept': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}` 'Authorization': `Bearer ${token}`
} }
}); });
if (response.status === 401) {
console.error('Authentication failed: Unauthorized');
setError('Your session has expired. Please log in again.');
// Fall back to mock data on auth error
const mockData = getMockPosts();
setPosts(mockData);
setTotalPosts(mockData.length);
setHasMore(false);
setLoading(false);
return;
}
if (!response.ok) { if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`); throw new Error(`API request failed with status: ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
console.log('API返回的posts数据:', data);
if (data && data.posts && Array.isArray(data.posts)) { if (data && Array.isArray(data.posts)) {
// 转换API返回的数据为前端需要的格式 // Transform API posts to frontend format
const frontendPosts: FrontendPost[] = data.posts.map((apiPost: ApiPost) => ({ const frontendPosts: FrontendPost[] = data.posts.map((apiPost: ApiPost) => ({
id: apiPost.post_id, id: apiPost.post_id,
title: apiPost.title || 'Untitled Post', title: apiPost.title || 'Untitled Post',
description: apiPost.description || '', description: apiPost.description || '',
author: apiPost.influencer?.name || 'Unknown Author', author: apiPost.influencer?.name || 'Unknown Author',
authorType: 'influencer', // 默认为influencer类型 authorType: 'influencer', // Default to influencer
platform: (apiPost.platform?.toLowerCase() || 'other') as any, platform: apiPost.platform as FrontendPost['platform'], // Cast to expected platform type
contentType: determineContentType(apiPost), contentType: determineContentType(apiPost),
timestamp: apiPost.published_at || apiPost.updated_at || new Date().toISOString(), timestamp: apiPost.published_at,
engagement: { engagement: {
views: apiPost.views_count, views: apiPost.views_count,
likes: apiPost.likes_count, likes: apiPost.likes_count,
@@ -154,21 +188,27 @@ const PostList: React.FC<PostListProps> = ({ influencerId, projectId }) => {
url: apiPost.post_url url: apiPost.post_url
})); }));
setTotalPosts(data.total || frontendPosts.length);
setPosts(frontendPosts); setPosts(frontendPosts);
setTotalPosts(data.total || frontendPosts.length);
setOffset(frontendPosts.length);
setHasMore(frontendPosts.length === pageSize);
} else { } else {
console.warn('API返回的数据格式不符合预期'); console.warn('Invalid data format received from API, using mock data');
setError('Failed to load posts data'); // Fall back to mock data if API response format is unexpected
// 使用模拟数据作为后备 const mockData = getMockPosts();
useMockData(); setPosts(mockData);
setTotalPosts(mockData.length);
setHasMore(false);
} }
} catch (err) {
setLoading(false); console.error('Error fetching posts:', err);
} catch (error) { setError('Failed to fetch posts. Using mock data instead.');
console.error('Error fetching posts:', error); // Fall back to mock data on error
setError('Failed to fetch posts. Please try again later.'); const mockData = getMockPosts();
// 使用模拟数据作为后备 setPosts(mockData);
useMockData(); setTotalPosts(mockData.length);
setHasMore(false);
} finally {
setLoading(false); setLoading(false);
} }
}; };
@@ -283,77 +323,207 @@ const PostList: React.FC<PostListProps> = ({ influencerId, projectId }) => {
return 'post'; return 'post';
}; };
// 使用模拟数据作为后备 // Helper function to get mock posts (extracted from useMockData)
const useMockData = () => { const getMockPosts = (): FrontendPost[] => {
const mockPosts: FrontendPost[] = [ return [
{ {
id: '1', id: '1',
title: 'Introduction to React Hooks', title: 'How to Get Started with React',
description: 'React Hooks are a powerful feature that allows you to use state and other React features without writing a class.', description: 'A beginner-friendly guide to React development',
author: 'John Smith', author: 'Jane Smith',
authorType: 'influencer', authorType: 'influencer',
platform: 'youtube', platform: 'youtube',
contentType: 'video', contentType: 'video',
timestamp: '2023-05-15T10:30:00Z', timestamp: '2023-05-15T14:30:00Z',
engagement: { engagement: {
views: 1500, views: 15000,
likes: 85, likes: 1200,
comments: 12, comments: 85,
shares: 5 shares: 320
}, },
url: 'https://youtube.com/watch?v=example1' url: 'https://youtube.com/watch?v=abc123'
}, },
{ // ... add more mock posts as needed
id: '2',
title: 'Advanced CSS Techniques',
description: 'Learn about the latest CSS techniques including Grid, Flexbox, and CSS Variables.',
author: 'Sarah Johnson',
authorType: 'influencer',
platform: 'instagram',
contentType: 'post',
timestamp: '2023-05-14T14:20:00Z',
engagement: {
views: 980,
likes: 72,
comments: 8,
shares: 3
},
url: 'https://instagram.com/p/example2'
},
{
id: '3',
title: 'JavaScript Performance Tips',
description: 'Optimize your JavaScript code with these performance tips and best practices.',
author: 'Michael Brown',
authorType: 'influencer',
platform: 'linkedin',
contentType: 'post',
timestamp: '2023-05-13T09:15:00Z',
engagement: {
views: 750,
likes: 68,
comments: 15,
shares: 10
},
url: 'https://linkedin.com/post/example3'
}
]; ];
setTotalPosts(mockPosts.length);
setPosts(mockPosts);
}; };
// 加载更多数据的函数
const loadMorePosts = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
console.log('Loading more posts from offset:', offset);
setIsLoadingMore(true);
try {
// 构建查询参数
const params = new URLSearchParams();
const pageSize = 20;
params.append('limit', pageSize.toString());
params.append('offset', offset.toString());
params.append('sort', 'published_at');
params.append('order', 'desc');
if (platformFilter && platformFilter !== 'all') {
params.append('platform', platformFilter);
}
if (influencerId) {
params.append('influencer_id', influencerId);
}
if (projectId) {
params.append('project_id', projectId);
}
// 获取认证token
const token = await getAuthToken();
if (!token) {
console.error('Authentication token not found');
setError('Authentication required. Please log in again.');
setIsLoadingMore(false);
return;
}
console.log('Fetching more posts with params:', params.toString());
// 发送API请求
const response = await fetch(`http://localhost:4000/api/posts?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401) {
console.error('Authentication failed: Unauthorized');
setError('Your session has expired. Please log in again.');
setIsLoadingMore(false);
return;
}
if (!response.ok) {
throw new Error(`API request failed with status: ${response.status}`);
}
const data = await response.json();
console.log('Received more posts:', data?.posts?.length || 0);
if (data && Array.isArray(data.posts)) {
// 转换API返回的数据为前端需要的格式
const newPosts: FrontendPost[] = data.posts.map((apiPost: ApiPost) => ({
id: apiPost.post_id,
title: apiPost.title || 'Untitled Post',
description: apiPost.description || '',
author: apiPost.influencer?.name || 'Unknown Author',
authorType: 'influencer', // 默认为influencer类型
platform: apiPost.platform as FrontendPost['platform'],
contentType: determineContentType(apiPost),
timestamp: apiPost.published_at,
engagement: {
views: apiPost.views_count,
likes: apiPost.likes_count,
comments: apiPost.comments_count,
shares: apiPost.shares_count
},
url: apiPost.post_url
}));
// 更新状态
setPosts(prevPosts => [...prevPosts, ...newPosts]);
setTotalPosts(data.total || totalPosts);
setOffset(prevOffset => prevOffset + newPosts.length);
// 检查是否还有更多数据
setHasMore(newPosts.length === pageSize);
console.log('Updated posts count:', posts.length + newPosts.length);
} else {
// 如果没有更多数据
console.log('No more posts available');
setHasMore(false);
}
} catch (err) {
console.error('Error fetching more posts:', err);
setError('Failed to fetch more posts.');
} finally {
setIsLoadingMore(false);
}
}, [offset, platformFilter, influencerId, projectId, isLoadingMore, hasMore, totalPosts, posts.length]);
// 设置Intersection Observer来监测滚动
useEffect(() => {
if (loading) return;
if (observer.current) {
observer.current.disconnect();
}
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
console.log('Intersection observed, loading more posts...');
loadMorePosts();
}
}, {
rootMargin: '100px', // 提前100px触发加载
threshold: 0.1 // 降低阈值,使其更容易触发
});
if (loadMoreRef.current) {
observer.current.observe(loadMoreRef.current);
}
return () => {
if (observer.current) {
observer.current.disconnect();
}
};
}, [loading, hasMore, isLoadingMore, loadMorePosts]);
// 在组件加载时获取数据 // 在组件加载时获取数据
useEffect(() => { useEffect(() => {
console.log('Fetching initial posts data...');
fetchPosts(); fetchPosts();
// 组件卸载时清理
return () => {
if (observer.current) {
observer.current.disconnect();
}
};
}, [influencerId, projectId, platformFilter]); }, [influencerId, projectId, platformFilter]);
// 获取Supabase会话和token的函数
const getAuthToken = async () => {
try {
// 尝试从localStorage中查找Supabase token
// Supabase通常将token存储在以sb-开头的键中
const keys = Object.keys(localStorage);
const supabaseTokenKey = keys.find(key => key.startsWith('sb-') && localStorage.getItem(key)?.includes('access_token'));
if (supabaseTokenKey) {
try {
const supabaseData = JSON.parse(localStorage.getItem(supabaseTokenKey) || '{}');
return supabaseData.access_token;
} catch (e) {
console.error('Error parsing Supabase token:', e);
}
}
// 如果没有找到Supabase token尝试使用常规token
return localStorage.getItem('token');
} catch (error) {
console.error('Error getting auth token:', error);
return null;
}
};
if (error) { if (error) {
return <div>{error}</div>; return <div>{error}</div>;
} }
return ( return (
<div> <div className="post-list-container">
<Card <Card
title="Posts" title="Posts"
extra={ extra={
@@ -367,11 +537,12 @@ const PostList: React.FC<PostListProps> = ({ influencerId, projectId }) => {
</Space> </Space>
} }
loading={loading} loading={loading}
className="mb-4"
> >
<Table <Table
dataSource={filteredPosts} dataSource={filteredPosts}
rowKey="id" rowKey="id"
pagination={{ pageSize: 10 }} pagination={false}
onRow={(post) => ({ onRow={(post) => ({
onClick: () => handleViewComments(post.id), onClick: () => handleViewComments(post.id),
style: { cursor: 'pointer' } style: { cursor: 'pointer' }
@@ -485,7 +656,32 @@ const PostList: React.FC<PostListProps> = ({ influencerId, projectId }) => {
), ),
}, },
]} ]}
scroll={{ x: 'max-content' }}
/> />
{/* 无限滚动加载指示器 */}
<div
ref={loadMoreRef}
style={{
textAlign: 'center',
padding: '20px 0',
visibility: loading || !hasMore ? 'hidden' : 'visible'
}}
>
{isLoadingMore && (
<div className="py-4">
<Spin tip="Loading more posts..." />
</div>
)}
{!hasMore && posts.length > 0 && (
<div className="py-4 text-gray-500">
No more posts to load
</div>
)}
{!hasMore && posts.length === 0 && !loading && (
<Empty description="No posts found" />
)}
</div>
</Card> </Card>
{/* Filters Drawer */} {/* Filters Drawer */}