- VideoAnalysis 组件性能优化:使用 memo/useMemo/useCallback,添加详情缓存和虚拟滚动 - 修复 Ant Design Modal/Descriptions/Table 内文字无法复制的问题 - 新增 AntdProvider 组件,解决 layout.tsx 不能加 'use client' 的问题 - 添加云图 API 参数测试,更新 CLAUDE.md 文档 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
407 lines
15 KiB
TypeScript
407 lines
15 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useMemo, useCallback, memo } from 'react';
|
||
import { Table, Input, Select, Button, Card, Space, message, Modal, Descriptions, Spin } from 'antd';
|
||
import { SearchOutlined, EyeOutlined } from '@ant-design/icons';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import { VideoAnalysisData } from '@/types';
|
||
import { searchVideos, VideoListItem, getVideoAnalysis } from '@/lib/api';
|
||
|
||
// 搜索类型选项
|
||
type SearchType = 'star_id' | 'unique_id' | 'nickname';
|
||
|
||
const SEARCH_TYPE_OPTIONS = [
|
||
{ value: 'star_id' as SearchType, label: '星图ID' },
|
||
{ value: 'unique_id' as SearchType, label: '达人unique_id' },
|
||
{ value: 'nickname' as SearchType, label: '达人昵称' },
|
||
];
|
||
|
||
const SEARCH_PLACEHOLDER: Record<SearchType, string> = {
|
||
star_id: '请输入星图ID',
|
||
unique_id: '请输入达人unique_id',
|
||
nickname: '请输入达人昵称关键词',
|
||
};
|
||
|
||
// 格式化数字(千分位,保留2位小数)
|
||
function formatNumber(num: number | null | undefined): string {
|
||
if (num === null || num === undefined) return '-';
|
||
return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
// 格式化整数(千分位)
|
||
function formatInt(num: number | null | undefined): string {
|
||
if (num === null || num === undefined) return '-';
|
||
return Math.round(num).toLocaleString('zh-CN');
|
||
}
|
||
|
||
// 详情弹窗组件 - 使用 memo 避免不必要的重渲染
|
||
const DetailModal = memo(function DetailModal({
|
||
visible,
|
||
data,
|
||
loading,
|
||
onClose,
|
||
}: {
|
||
visible: boolean;
|
||
data: VideoAnalysisData | null;
|
||
loading: boolean;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<Modal
|
||
title="视频详情"
|
||
open={visible}
|
||
onCancel={onClose}
|
||
footer={null}
|
||
width={900}
|
||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', userSelect: 'text', WebkitUserSelect: 'text' } }}
|
||
>
|
||
{loading ? (
|
||
<div style={{ textAlign: 'center', padding: 50 }}>
|
||
<Spin size="large" />
|
||
</div>
|
||
) : data ? (
|
||
<Space direction="vertical" size="middle" style={{ width: '100%', userSelect: 'text', WebkitUserSelect: 'text' } as React.CSSProperties}>
|
||
{/* 基础信息 */}
|
||
<Descriptions title="基础信息" bordered size="small" column={2} contentStyle={{ userSelect: 'text', cursor: 'text' }}>
|
||
<Descriptions.Item label="达人昵称"><span style={{ userSelect: 'text' }}>{data.base_info.star_nickname || '-'}</span></Descriptions.Item>
|
||
<Descriptions.Item label="达人unique_id"><span style={{ userSelect: 'text' }}>{data.base_info.star_unique_id || '-'}</span></Descriptions.Item>
|
||
<Descriptions.Item label="视频ID">{data.base_info.vid || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="发布时间">{data.base_info.create_date || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="爆文类型">{data.base_info.hot_type || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="合作行业">{data.base_info.industry_id || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="合作品牌">{data.base_info.brand_name || data.base_info.brand_id || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="视频标题" span={2}>
|
||
{data.base_info.video_url ? (
|
||
<a href={data.base_info.video_url} target="_blank" rel="noopener noreferrer">
|
||
{data.base_info.title || '查看视频'}
|
||
</a>
|
||
) : (
|
||
data.base_info.title || '-'
|
||
)}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
{/* 触达指标 */}
|
||
<Descriptions title="触达指标" bordered size="small" column={4} contentStyle={{ userSelect: 'text', cursor: 'text' }}>
|
||
<Descriptions.Item label="自然曝光数">{formatInt(data.reach_metrics.natural_play_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="加热曝光数">{formatInt(data.reach_metrics.heated_play_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="总曝光数">{formatInt(data.reach_metrics.total_play_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="总互动">{formatInt(data.reach_metrics.total_interaction_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="点赞">{formatInt(data.reach_metrics.digg_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="转发">{formatInt(data.reach_metrics.share_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="评论">{formatInt(data.reach_metrics.comment_cnt)}</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
{/* A3指标 */}
|
||
<Descriptions title="A3指标" bordered size="small" column={3} contentStyle={{ userSelect: 'text', cursor: 'text' }}>
|
||
<Descriptions.Item label="新增A3">{formatInt(data.a3_metrics.total_new_a3_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="加热新增A3">{formatInt(data.a3_metrics.heated_new_a3_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="自然新增A3">{formatInt(data.a3_metrics.natural_new_a3_cnt)}</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
{/* 搜索指标 */}
|
||
<Descriptions title="搜索指标" bordered size="small" column={3} contentStyle={{ userSelect: 'text', cursor: 'text' }}>
|
||
<Descriptions.Item label="回搜人数">{formatInt(data.search_metrics.back_search_uv)}</Descriptions.Item>
|
||
<Descriptions.Item label="回搜次数">{formatInt(data.search_metrics.back_search_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="看后搜人数">{formatInt(data.search_metrics.after_view_search_uv)}</Descriptions.Item>
|
||
<Descriptions.Item label="看后搜次数">{formatInt(data.search_metrics.after_view_search_cnt)}</Descriptions.Item>
|
||
<Descriptions.Item label="预估自然看后搜人数">{formatNumber(data.search_metrics.estimated_natural_search_uv)}</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
{/* 费用指标 */}
|
||
<Descriptions title="费用指标" bordered size="small" column={3} contentStyle={{ userSelect: 'text', cursor: 'text' }}>
|
||
<Descriptions.Item label="预估总费用">{formatNumber(data.cost_metrics.total_cost)}</Descriptions.Item>
|
||
<Descriptions.Item label="预估加热费用">{formatNumber(data.cost_metrics.heated_cost)}</Descriptions.Item>
|
||
<Descriptions.Item label="预估视频采买费用">{formatNumber(data.cost_metrics.estimated_video_cost)}</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
{/* 成本指标 */}
|
||
<Descriptions title="成本指标" bordered size="small" column={3} contentStyle={{ userSelect: 'text', cursor: 'text' }}>
|
||
<Descriptions.Item label="预估CPM">{formatNumber(data.calculated_metrics.estimated_cpm)}</Descriptions.Item>
|
||
<Descriptions.Item label="预估自然CPM">{formatNumber(data.calculated_metrics.estimated_natural_cpm)}</Descriptions.Item>
|
||
<Descriptions.Item label="预估CPA3">{formatNumber(data.calculated_metrics.estimated_cp_a3)}</Descriptions.Item>
|
||
<Descriptions.Item label="预估自然CPA3">{formatNumber(data.calculated_metrics.estimated_natural_cp_a3)}</Descriptions.Item>
|
||
<Descriptions.Item label="预估CPsearch">{formatNumber(data.calculated_metrics.estimated_cp_search)}</Descriptions.Item>
|
||
<Descriptions.Item label="自然CPsearch">{formatNumber(data.calculated_metrics.estimated_natural_cp_search)}</Descriptions.Item>
|
||
</Descriptions>
|
||
</Space>
|
||
) : null}
|
||
</Modal>
|
||
);
|
||
});
|
||
|
||
export default function VideoAnalysis() {
|
||
const [searchType, setSearchType] = useState<SearchType>('star_id');
|
||
const [searchValue, setSearchValue] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [listData, setListData] = useState<VideoListItem[]>([]);
|
||
|
||
// 详情弹窗状态
|
||
const [detailVisible, setDetailVisible] = useState(false);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [detailData, setDetailData] = useState<VideoAnalysisData | null>(null);
|
||
|
||
// 详情缓存 - 避免重复请求
|
||
const [detailCache, setDetailCache] = useState<Record<string, VideoAnalysisData>>({});
|
||
|
||
// 使用 useCallback 包裹搜索处理器
|
||
const handleSearch = useCallback(async () => {
|
||
if (!searchValue.trim()) {
|
||
message.warning(`请输入${SEARCH_TYPE_OPTIONS.find(o => o.value === searchType)?.label}`);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const response = await searchVideos({
|
||
type: searchType,
|
||
value: searchValue.trim(),
|
||
});
|
||
|
||
if (response.success) {
|
||
setListData(response.data as VideoListItem[]);
|
||
if ((response.data as VideoListItem[]).length === 0) {
|
||
message.info('未找到相关视频');
|
||
}
|
||
} else {
|
||
message.error(response.error || '搜索失败');
|
||
}
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : '搜索失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [searchType, searchValue]);
|
||
|
||
// 使用 useCallback 包裹详情查看处理器,带缓存逻辑
|
||
const handleViewDetail = useCallback(async (itemId: string) => {
|
||
// 检查缓存
|
||
if (detailCache[itemId]) {
|
||
setDetailData(detailCache[itemId]);
|
||
setDetailVisible(true);
|
||
return;
|
||
}
|
||
|
||
setDetailVisible(true);
|
||
setDetailLoading(true);
|
||
setDetailData(null);
|
||
|
||
try {
|
||
const response = await getVideoAnalysis(itemId);
|
||
if (response.success) {
|
||
setDetailData(response.data);
|
||
// 缓存结果
|
||
setDetailCache(prev => ({ ...prev, [itemId]: response.data }));
|
||
} else {
|
||
message.error(response.error || '获取详情失败');
|
||
setDetailVisible(false);
|
||
}
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : '获取详情失败');
|
||
setDetailVisible(false);
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
}, [detailCache]);
|
||
|
||
// 关闭详情弹窗的回调
|
||
const handleCloseDetail = useCallback(() => {
|
||
setDetailVisible(false);
|
||
}, []);
|
||
|
||
// 使用 useMemo 包裹表格列定义,避免每次重建
|
||
const columns: ColumnsType<VideoListItem> = useMemo(() => [
|
||
{
|
||
title: '达人昵称',
|
||
dataIndex: 'star_nickname',
|
||
key: 'star_nickname',
|
||
width: 120,
|
||
fixed: 'left',
|
||
render: (text) => text || '-',
|
||
},
|
||
{
|
||
title: '视频标题',
|
||
dataIndex: 'title',
|
||
key: 'title',
|
||
width: 200,
|
||
ellipsis: true,
|
||
render: (text, record) =>
|
||
record.video_url ? (
|
||
<a href={record.video_url} target="_blank" rel="noopener noreferrer">
|
||
{text || '查看视频'}
|
||
</a>
|
||
) : (
|
||
text || '-'
|
||
),
|
||
},
|
||
{
|
||
title: '发布时间',
|
||
dataIndex: 'create_date',
|
||
key: 'create_date',
|
||
width: 110,
|
||
render: (text) => (text ? text.split('T')[0] : '-'),
|
||
},
|
||
{
|
||
title: '爆文类型',
|
||
dataIndex: 'hot_type',
|
||
key: 'hot_type',
|
||
width: 90,
|
||
render: (text) => text || '-',
|
||
},
|
||
{
|
||
title: '合作行业',
|
||
dataIndex: 'industry_id',
|
||
key: 'industry_id',
|
||
width: 90,
|
||
render: (text) => text || '-',
|
||
},
|
||
{
|
||
title: '合作品牌',
|
||
dataIndex: 'brand_name',
|
||
key: 'brand_name',
|
||
width: 100,
|
||
render: (text) => text || '-',
|
||
},
|
||
{
|
||
title: '新增A3',
|
||
dataIndex: 'total_new_a3_cnt',
|
||
key: 'total_new_a3_cnt',
|
||
width: 90,
|
||
align: 'right',
|
||
render: (val) => formatInt(val),
|
||
},
|
||
{
|
||
title: '加热A3',
|
||
dataIndex: 'heated_new_a3_cnt',
|
||
key: 'heated_new_a3_cnt',
|
||
width: 90,
|
||
align: 'right',
|
||
render: (val) => formatInt(val),
|
||
},
|
||
{
|
||
title: '自然A3',
|
||
dataIndex: 'natural_new_a3_cnt',
|
||
key: 'natural_new_a3_cnt',
|
||
width: 90,
|
||
align: 'right',
|
||
render: (val) => formatInt(val),
|
||
},
|
||
{
|
||
title: '预估自然CPM',
|
||
dataIndex: 'estimated_natural_cpm',
|
||
key: 'estimated_natural_cpm',
|
||
width: 110,
|
||
align: 'right',
|
||
render: (val) => formatNumber(val),
|
||
},
|
||
{
|
||
title: '预估CPA3',
|
||
dataIndex: 'estimated_cp_a3',
|
||
key: 'estimated_cp_a3',
|
||
width: 100,
|
||
align: 'right',
|
||
render: (val) => formatNumber(val),
|
||
},
|
||
{
|
||
title: '预估自然CPA3',
|
||
dataIndex: 'estimated_natural_cp_a3',
|
||
key: 'estimated_natural_cp_a3',
|
||
width: 110,
|
||
align: 'right',
|
||
render: (val) => formatNumber(val),
|
||
},
|
||
{
|
||
title: '预估CPsearch',
|
||
dataIndex: 'estimated_cp_search',
|
||
key: 'estimated_cp_search',
|
||
width: 110,
|
||
align: 'right',
|
||
render: (val) => formatNumber(val),
|
||
},
|
||
{
|
||
title: '自然CPsearch',
|
||
dataIndex: 'estimated_natural_cp_search',
|
||
key: 'estimated_natural_cp_search',
|
||
width: 110,
|
||
align: 'right',
|
||
render: (val) => formatNumber(val),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 80,
|
||
fixed: 'right',
|
||
render: (_, record) => (
|
||
<Button
|
||
type="link"
|
||
icon={<EyeOutlined />}
|
||
onClick={() => handleViewDetail(record.item_id)}
|
||
>
|
||
详情
|
||
</Button>
|
||
),
|
||
},
|
||
], [handleViewDetail]);
|
||
|
||
return (
|
||
<div style={{ padding: 24, maxWidth: 1600, margin: '0 auto' }}>
|
||
{/* 搜索区域 */}
|
||
<Card style={{ marginBottom: 24 }}>
|
||
<h1 style={{ fontSize: 20, fontWeight: 'bold', marginBottom: 16 }}>KOL 视频分析</h1>
|
||
<Space.Compact style={{ width: '100%', maxWidth: 600 }}>
|
||
<Select
|
||
value={searchType}
|
||
onChange={(val) => {
|
||
setSearchType(val);
|
||
setSearchValue('');
|
||
}}
|
||
style={{ width: 140 }}
|
||
options={SEARCH_TYPE_OPTIONS}
|
||
/>
|
||
<Input
|
||
value={searchValue}
|
||
onChange={(e) => setSearchValue(e.target.value)}
|
||
placeholder={SEARCH_PLACEHOLDER[searchType]}
|
||
onPressEnter={handleSearch}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<SearchOutlined />}
|
||
onClick={handleSearch}
|
||
loading={loading}
|
||
>
|
||
查询
|
||
</Button>
|
||
</Space.Compact>
|
||
</Card>
|
||
|
||
{/* 结果表格 - 启用虚拟滚动 */}
|
||
<Card>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={listData}
|
||
rowKey="item_id"
|
||
loading={loading}
|
||
virtual
|
||
scroll={{ x: 1800, y: 600 }}
|
||
pagination={{
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
}}
|
||
locale={{ emptyText: '暂无数据,请输入搜索条件' }}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 详情弹窗 */}
|
||
<DetailModal
|
||
visible={detailVisible}
|
||
data={detailData}
|
||
loading={detailLoading}
|
||
onClose={handleCloseDetail}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|