feat(frontend): 重构视频分析页面,支持多种搜索方式
主要更新: - 前端改用 Ant Design 组件(Table、Modal、Select 等) - 支持三种搜索方式:星图ID、达人unique_id、达人昵称模糊匹配 - 列表页实时调用云图 API 获取 A3 数据和成本指标 - 详情弹窗显示完整 6 大类指标,支持文字复制 - 品牌 API URL 格式修复为查询参数形式 - 优化云图 API 参数格式和会话池管理 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"antd": "^6.2.2",
|
||||
"next": "14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
|
||||
Generated
+859
-8
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ const geistMono = localFont({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'KOL Insight - 云图数据查询分析',
|
||||
description: 'KOL 视频数据查询与成本分析工具 - 麦秒思AI制作',
|
||||
description: 'KOL 视频数据查询与成本分析工具 - 秒思AI制作',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
+2
-112
@@ -1,117 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { QueryForm, ResultTable, ExportButton } from '@/components';
|
||||
import { QueryType, VideoData, PageState } from '@/types';
|
||||
import { queryVideos } from '@/lib/api';
|
||||
import VideoAnalysis from '@/components/VideoAnalysis';
|
||||
|
||||
export default function Home() {
|
||||
const [pageState, setPageState] = useState<PageState>('default');
|
||||
const [data, setData] = useState<VideoData[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleQuery = async (type: QueryType, values: string[]) => {
|
||||
setPageState('loading');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await queryVideos({ type, values });
|
||||
|
||||
if (response.success) {
|
||||
setData(response.data);
|
||||
setTotal(response.total);
|
||||
setPageState(response.total > 0 ? 'result' : 'empty');
|
||||
} else {
|
||||
setError(response.error || '查询失败');
|
||||
setPageState('error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Query error:', err);
|
||||
setError(err instanceof Error ? err.message : '网络错误,请检查后端服务是否正常');
|
||||
setPageState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setPageState('default');
|
||||
setError(null);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* 查询区域 */}
|
||||
<section className="mb-8">
|
||||
<QueryForm onSubmit={handleQuery} isLoading={pageState === 'loading'} />
|
||||
</section>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<section>
|
||||
{/* 默认态 */}
|
||||
{pageState === 'default' && (
|
||||
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<div className="text-gray-400 text-6xl mb-4">🔍</div>
|
||||
<p className="text-gray-500 mb-4">请选择查询方式并输入查询条件</p>
|
||||
<a
|
||||
href="/analysis"
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
或前往视频分析页面 →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载态 */}
|
||||
{pageState === 'loading' && (
|
||||
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<div className="animate-spin text-primary text-4xl mb-4">⟳</div>
|
||||
<p className="text-gray-500">正在查询数据,请稍候...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果态 */}
|
||||
{pageState === 'result' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">查询结果</h2>
|
||||
<ExportButton hasData={total > 0} />
|
||||
</div>
|
||||
<ResultTable data={data} total={total} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空结果态 */}
|
||||
{pageState === 'empty' && (
|
||||
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<div className="text-gray-400 text-6xl mb-4">📦</div>
|
||||
<p className="text-gray-700 mb-2">未找到匹配数据</p>
|
||||
<p className="text-gray-500 text-sm mb-4">请调整查询条件后重新尝试</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="px-4 py-2 text-sm font-medium text-primary border border-primary rounded hover:bg-primary hover:text-white"
|
||||
>
|
||||
修改查询条件
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误态 */}
|
||||
{pageState === 'error' && (
|
||||
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<div className="text-error text-6xl mb-4">⚠</div>
|
||||
<p className="text-gray-700 mb-2">查询失败,请重试</p>
|
||||
<p className="text-gray-500 text-sm mb-4">{error || '可能原因:网络异常或数据库连接失败'}</p>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary rounded hover:bg-primary-dark"
|
||||
>
|
||||
重新查询
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
return <VideoAnalysis />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-gray-50 border-t border-gray-200 py-4 px-6">
|
||||
<div className="max-w-7xl mx-auto text-center text-sm text-gray-500">
|
||||
© 2026 麦秒思AI制作 | KOL Insight v1.0
|
||||
© 2026 秒思AI制作 | KOL Insight v1.0
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -5,13 +5,13 @@ export default function Header() {
|
||||
<header className="bg-white border-b border-gray-200 py-4 px-6">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Image src="/muse.svg" alt="麦秒思AI Logo" width={40} height={40} priority />
|
||||
<Image src="/muse.svg" alt="秒思AI Logo" width={40} height={40} priority />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">KOL Insight</h1>
|
||||
<p className="text-sm text-gray-500">云图数据查询分析</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">麦秒思AI制作</div>
|
||||
<div className="text-sm text-gray-500">秒思AI制作</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,178 +1,386 @@
|
||||
'use client';
|
||||
|
||||
import { useState } 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 { getVideoAnalysis } from '@/lib/api';
|
||||
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');
|
||||
return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
// 格式化金额(保留2位小数)
|
||||
function formatCurrency(num: number | null | undefined): string {
|
||||
// 格式化整数(千分位)
|
||||
function formatInt(num: number | null | undefined): string {
|
||||
if (num === null || num === undefined) return '-';
|
||||
return `¥${num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
return Math.round(num).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
// 指标卡片组件
|
||||
function MetricCard({ label, value, unit }: { label: string; value: string; unit?: string }) {
|
||||
// 详情弹窗组件
|
||||
function DetailModal({
|
||||
visible,
|
||||
data,
|
||||
loading,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
data: VideoAnalysisData | null;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-sm text-gray-500 mb-1">{label}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{value}
|
||||
{unit && <span className="text-sm font-normal text-gray-500 ml-1">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Modal
|
||||
title="视频详情"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={900}
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', userSelect: 'text' } }}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 50 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : data ? (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{/* 基础信息 */}
|
||||
<Descriptions title="基础信息" bordered size="small" column={2}>
|
||||
<Descriptions.Item label="达人昵称">{data.base_info.star_nickname || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="达人unique_id">{data.base_info.star_unique_id || '-'}</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>
|
||||
|
||||
// 指标分组组件
|
||||
function MetricGroup({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-md font-semibold text-gray-800 mb-3 pb-2 border-b">{title}</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{/* 触达指标 */}
|
||||
<Descriptions title="触达指标" bordered size="small" column={4}>
|
||||
<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}>
|
||||
<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}>
|
||||
<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}>
|
||||
<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}>
|
||||
<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 [itemId, setItemId] = useState('');
|
||||
const [searchType, setSearchType] = useState<SearchType>('star_id');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<VideoAnalysisData | null>(null);
|
||||
const [listData, setListData] = useState<VideoListItem[]>([]);
|
||||
|
||||
// 详情弹窗状态
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailData, setDetailData] = useState<VideoAnalysisData | null>(null);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!itemId.trim()) {
|
||||
setError('请输入视频ID');
|
||||
if (!searchValue.trim()) {
|
||||
message.warning(`请输入${SEARCH_TYPE_OPTIONS.find(o => o.value === searchType)?.label}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getVideoAnalysis(itemId.trim());
|
||||
const response = await searchVideos({
|
||||
type: searchType,
|
||||
value: searchValue.trim(),
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setData(response.data);
|
||||
setListData(response.data as VideoListItem[]);
|
||||
if ((response.data as VideoListItem[]).length === 0) {
|
||||
message.info('未找到相关视频');
|
||||
}
|
||||
} else {
|
||||
setError(response.error || '获取数据失败');
|
||||
message.error(response.error || '搜索失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||
message.error(err instanceof Error ? err.message : '搜索失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = async (itemId: string) => {
|
||||
setDetailVisible(true);
|
||||
setDetailLoading(true);
|
||||
setDetailData(null);
|
||||
|
||||
try {
|
||||
const response = await getVideoAnalysis(itemId);
|
||||
if (response.success) {
|
||||
setDetailData(response.data);
|
||||
} else {
|
||||
message.error(response.error || '获取详情失败');
|
||||
setDetailVisible(false);
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '获取详情失败');
|
||||
setDetailVisible(false);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<VideoListItem> = [
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">视频分析</h2>
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={itemId}
|
||||
onChange={(e) => setItemId(e.target.value)}
|
||||
placeholder="请输入视频ID (item_id)"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
<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}
|
||||
/>
|
||||
<button
|
||||
<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}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? '加载中...' : '查询'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-3 text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
查询
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Card>
|
||||
|
||||
{/* 分析结果 */}
|
||||
{data && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
{/* 基础信息 */}
|
||||
<MetricGroup title="基础信息">
|
||||
<MetricCard label="视频ID" value={data.base_info.item_id} />
|
||||
<MetricCard label="达人昵称" value={data.base_info.star_nickname} />
|
||||
<MetricCard label="达人ID" value={data.base_info.star_unique_id} />
|
||||
<MetricCard label="星图ID" value={data.base_info.star_id} />
|
||||
<MetricCard label="发布时间" value={data.base_info.publish_time || '-'} />
|
||||
<MetricCard label="行业" value={data.base_info.industry_name || '-'} />
|
||||
<div className="col-span-2">
|
||||
<MetricCard label="视频标题" value={data.base_info.title || '-'} />
|
||||
</div>
|
||||
</MetricGroup>
|
||||
{/* 结果表格 */}
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={listData}
|
||||
rowKey="item_id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1800 }}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
locale={{ emptyText: '暂无数据,请输入搜索条件' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 触达指标 */}
|
||||
<MetricGroup title="触达指标">
|
||||
<MetricCard label="总曝光数" value={formatNumber(data.reach_metrics.total_show_cnt)} />
|
||||
<MetricCard label="自然曝光数" value={formatNumber(data.reach_metrics.natural_show_cnt)} />
|
||||
<MetricCard label="加热曝光数" value={formatNumber(data.reach_metrics.ad_show_cnt)} />
|
||||
<MetricCard label="总播放数" value={formatNumber(data.reach_metrics.total_play_cnt)} />
|
||||
<MetricCard label="自然播放数" value={formatNumber(data.reach_metrics.natural_play_cnt)} />
|
||||
<MetricCard label="加热播放数" value={formatNumber(data.reach_metrics.ad_play_cnt)} />
|
||||
<MetricCard label="有效播放数" value={formatNumber(data.reach_metrics.effective_play_cnt)} />
|
||||
</MetricGroup>
|
||||
|
||||
{/* A3指标 */}
|
||||
<MetricGroup title="A3指标">
|
||||
<MetricCard label="新增A3" value={formatNumber(data.a3_metrics.a3_increase_cnt)} />
|
||||
<MetricCard label="加热新增A3" value={formatNumber(data.a3_metrics.ad_a3_increase_cnt)} />
|
||||
<MetricCard label="自然新增A3" value={formatNumber(data.a3_metrics.natural_a3_increase_cnt)} />
|
||||
</MetricGroup>
|
||||
|
||||
{/* 搜索指标 */}
|
||||
<MetricGroup title="搜索指标">
|
||||
<MetricCard label="看后搜人数" value={formatNumber(data.search_metrics.after_view_search_uv)} />
|
||||
<MetricCard label="看后搜次数" value={formatNumber(data.search_metrics.after_view_search_pv)} />
|
||||
<MetricCard label="品牌搜索人数" value={formatNumber(data.search_metrics.brand_search_uv)} />
|
||||
<MetricCard label="商品搜索人数" value={formatNumber(data.search_metrics.product_search_uv)} />
|
||||
<MetricCard label="回搜次数" value={formatNumber(data.search_metrics.return_search_cnt)} />
|
||||
</MetricGroup>
|
||||
|
||||
{/* 费用指标 */}
|
||||
<MetricGroup title="费用指标">
|
||||
<MetricCard label="总花费" value={formatCurrency(data.cost_metrics_raw.cost)} />
|
||||
<MetricCard label="自然花费" value={formatCurrency(data.cost_metrics_raw.natural_cost)} />
|
||||
<MetricCard label="加热花费" value={formatCurrency(data.cost_metrics_raw.ad_cost)} />
|
||||
</MetricGroup>
|
||||
|
||||
{/* 成本指标 */}
|
||||
<MetricGroup title="成本指标(计算)">
|
||||
<MetricCard label="CPM" value={formatCurrency(data.cost_metrics_calculated.cpm)} />
|
||||
<MetricCard label="自然CPM" value={formatCurrency(data.cost_metrics_calculated.natural_cpm)} />
|
||||
<MetricCard label="CPA3" value={formatCurrency(data.cost_metrics_calculated.cpa3)} />
|
||||
<MetricCard label="自然CPA3" value={formatCurrency(data.cost_metrics_calculated.natural_cpa3)} />
|
||||
<MetricCard label="CP搜索" value={formatCurrency(data.cost_metrics_calculated.cp_search)} />
|
||||
<MetricCard label="预估自然看后搜人数" value={formatNumber(data.cost_metrics_calculated.estimated_natural_search_uv)} />
|
||||
<MetricCard label="自然CP搜索" value={formatCurrency(data.cost_metrics_calculated.natural_cp_search)} />
|
||||
</MetricGroup>
|
||||
|
||||
{/* 视频链接 */}
|
||||
{data.base_info.video_url && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<a
|
||||
href={data.base_info.video_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
查看原视频 →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 详情弹窗 */}
|
||||
<DetailModal
|
||||
visible={detailVisible}
|
||||
data={detailData}
|
||||
loading={detailLoading}
|
||||
onClose={() => setDetailVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+57
-1
@@ -1,4 +1,4 @@
|
||||
import { QueryRequest, QueryResponse, VideoAnalysisResponse } from '@/types';
|
||||
import { QueryRequest, QueryResponse, VideoAnalysisResponse, VideoAnalysisData } from '@/types';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
|
||||
|
||||
@@ -41,3 +41,59 @@ export async function getVideoAnalysis(itemId: string): Promise<VideoAnalysisRes
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 搜索视频
|
||||
export interface SearchRequest {
|
||||
type: 'star_id' | 'unique_id' | 'nickname';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface VideoListItem {
|
||||
item_id: string;
|
||||
// 基础信息(标1)
|
||||
star_nickname: string;
|
||||
title: string;
|
||||
video_url: string;
|
||||
create_date: string | null;
|
||||
hot_type: string;
|
||||
industry_id: string;
|
||||
brand_id: string;
|
||||
brand_name: string;
|
||||
// A3指标(标1)
|
||||
total_new_a3_cnt: number;
|
||||
heated_new_a3_cnt: number;
|
||||
natural_new_a3_cnt: number;
|
||||
// 成本指标(标1)
|
||||
estimated_natural_cpm: number | null;
|
||||
estimated_cp_a3: number | null;
|
||||
estimated_natural_cp_a3: number | null;
|
||||
estimated_cp_search: number | null;
|
||||
estimated_natural_cp_search: number | null;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
success: boolean;
|
||||
type: 'detail' | 'list';
|
||||
data: VideoAnalysisData | VideoListItem[];
|
||||
total: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function searchVideos(request: SearchRequest): Promise<SearchResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/videos/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('未找到相关视频');
|
||||
}
|
||||
throw new Error(`搜索失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
+41
-34
@@ -62,52 +62,59 @@ export const QUERY_PLACEHOLDER: Record<QueryType, string> = {
|
||||
nickname: '请输入达人昵称关键词...',
|
||||
};
|
||||
|
||||
// 视频分析数据 (T-026)
|
||||
// 视频分析数据 (按用户字段对照表定义6大类)
|
||||
export interface VideoAnalysisData {
|
||||
// 基础信息
|
||||
base_info: {
|
||||
item_id: string;
|
||||
title: string | null;
|
||||
video_url: string | null;
|
||||
star_id: string;
|
||||
star_unique_id: string;
|
||||
star_nickname: string;
|
||||
publish_time: string | null;
|
||||
industry_name: string | null;
|
||||
star_unique_id: string;
|
||||
vid: string;
|
||||
title: string;
|
||||
create_date: string | null;
|
||||
hot_type: string;
|
||||
industry_id: string;
|
||||
brand_id: string;
|
||||
brand_name: string;
|
||||
video_url: string;
|
||||
};
|
||||
// 触达指标
|
||||
reach_metrics: {
|
||||
total_show_cnt: number;
|
||||
natural_show_cnt: number;
|
||||
ad_show_cnt: number;
|
||||
total_play_cnt: number;
|
||||
natural_play_cnt: number;
|
||||
ad_play_cnt: number;
|
||||
effective_play_cnt: number;
|
||||
heated_play_cnt: number;
|
||||
total_play_cnt: number;
|
||||
total_interaction_cnt: number;
|
||||
digg_cnt: number;
|
||||
share_cnt: number;
|
||||
comment_cnt: number;
|
||||
};
|
||||
// A3指标
|
||||
a3_metrics: {
|
||||
a3_increase_cnt: number;
|
||||
ad_a3_increase_cnt: number;
|
||||
natural_a3_increase_cnt: number;
|
||||
total_new_a3_cnt: number;
|
||||
heated_new_a3_cnt: number;
|
||||
natural_new_a3_cnt: number;
|
||||
};
|
||||
// 搜索指标
|
||||
search_metrics: {
|
||||
back_search_uv: number;
|
||||
back_search_cnt: number;
|
||||
after_view_search_uv: number;
|
||||
after_view_search_pv: number;
|
||||
brand_search_uv: number;
|
||||
product_search_uv: number;
|
||||
return_search_cnt: number;
|
||||
};
|
||||
cost_metrics_raw: {
|
||||
cost: number;
|
||||
natural_cost: number;
|
||||
ad_cost: number;
|
||||
};
|
||||
cost_metrics_calculated: {
|
||||
cpm: number | null;
|
||||
natural_cpm: number | null;
|
||||
cpa3: number | null;
|
||||
natural_cpa3: number | null;
|
||||
cp_search: number | null;
|
||||
after_view_search_cnt: number;
|
||||
estimated_natural_search_uv: number | null;
|
||||
natural_cp_search: number | null;
|
||||
};
|
||||
// 费用指标
|
||||
cost_metrics: {
|
||||
total_cost: number;
|
||||
heated_cost: number;
|
||||
estimated_video_cost: number;
|
||||
};
|
||||
// 成本指标(实时计算)
|
||||
calculated_metrics: {
|
||||
estimated_cpm: number | null;
|
||||
estimated_natural_cpm: number | null;
|
||||
estimated_cp_a3: number | null;
|
||||
estimated_natural_cp_a3: number | null;
|
||||
estimated_cp_search: number | null;
|
||||
estimated_natural_cp_search: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user