kol-insight/frontend/src/components/VideoAnalysis.tsx
zfc 70ba2f1868 feat(frontend): 优化前端性能与修复文字选择问题
- 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>
2026-01-29 12:04:47 +08:00

407 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}