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:
zfc
2026-01-28 22:01:55 +08:00
co-authored by Claude Opus 4.5
parent f123f68be3
commit 7cd29c5980
25 changed files with 2482 additions and 1324 deletions
+2
View File
@@ -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"
+859 -8
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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 />;
}
+1 -1
View File
@@ -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>
);
+2 -2
View File
@@ -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>
);
+342 -134
View File
@@ -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
View File
@@ -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
View File
@@ -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;
};
}