feat(video-analysis): 完成视频分析模块迭代任务
Bug 修复:
- T-019: 修复品牌API响应解析,正确解析 data[0].brand_name
- T-020: 添加品牌API Bearer Token认证
视频分析功能:
- T-021: SessionID池服务,从内部API获取Cookie列表
- T-022: SessionID自动重试,失效时自动切换重试
- T-023: 巨量云图API封装,支持超时和错误处理
- T-024: 视频分析数据接口 GET /api/v1/videos/{item_id}/analysis
- T-025: 数据库A3指标更新
- T-026: 视频分析前端页面,展示6大类25+指标
测试覆盖率:
- brand_api.py: 100%
- session_pool.py: 100%
- yuntu_api.py: 100%
- video_analysis.py: 99%
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import VideoAnalysis from '@/components/VideoAnalysis';
|
||||
|
||||
export default function AnalysisPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<a href="/" className="text-indigo-600 hover:text-indigo-800">
|
||||
← 返回查询
|
||||
</a>
|
||||
</div>
|
||||
<VideoAnalysis />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,13 @@ export default function Home() {
|
||||
{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">请选择查询方式并输入查询条件</p>
|
||||
<p className="text-gray-500 mb-4">请选择查询方式并输入查询条件</p>
|
||||
<a
|
||||
href="/analysis"
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
或前往视频分析页面 →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { VideoAnalysisData } from '@/types';
|
||||
import { getVideoAnalysis } from '@/lib/api';
|
||||
|
||||
// 格式化数字(千分位)
|
||||
function formatNumber(num: number | null | undefined): string {
|
||||
if (num === null || num === undefined) return '-';
|
||||
return num.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
// 格式化金额(保留2位小数)
|
||||
function formatCurrency(num: number | null | undefined): string {
|
||||
if (num === null || num === undefined) return '-';
|
||||
return `¥${num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
// 指标卡片组件
|
||||
function MetricCard({ label, value, unit }: { label: string; value: string; unit?: string }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 指标分组组件
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoAnalysis() {
|
||||
const [itemId, setItemId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<VideoAnalysisData | null>(null);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!itemId.trim()) {
|
||||
setError('请输入视频ID');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getVideoAnalysis(itemId.trim());
|
||||
if (response.success) {
|
||||
setData(response.data);
|
||||
} else {
|
||||
setError(response.error || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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()}
|
||||
/>
|
||||
<button
|
||||
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 ? '加载中...' : '查询'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-3 text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分析结果 */}
|
||||
{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>
|
||||
|
||||
{/* 触达指标 */}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
import { QueryRequest, QueryResponse } from '@/types';
|
||||
import { QueryRequest, QueryResponse, VideoAnalysisResponse } from '@/types';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
|
||||
|
||||
@@ -27,3 +27,17 @@ export async function exportData(format: 'xlsx' | 'csv'): Promise<Blob> {
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// 获取视频分析数据 (T-026)
|
||||
export async function getVideoAnalysis(itemId: string): Promise<VideoAnalysisResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/videos/${itemId}/analysis`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('视频不存在');
|
||||
}
|
||||
throw new Error(`获取分析数据失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -61,3 +61,58 @@ export const QUERY_PLACEHOLDER: Record<QueryType, string> = {
|
||||
unique_id: '请输入达人unique_id,每行一个...',
|
||||
nickname: '请输入达人昵称关键词...',
|
||||
};
|
||||
|
||||
// 视频分析数据 (T-026)
|
||||
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;
|
||||
};
|
||||
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;
|
||||
};
|
||||
a3_metrics: {
|
||||
a3_increase_cnt: number;
|
||||
ad_a3_increase_cnt: number;
|
||||
natural_a3_increase_cnt: number;
|
||||
};
|
||||
search_metrics: {
|
||||
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;
|
||||
estimated_natural_search_uv: number | null;
|
||||
natural_cp_search: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VideoAnalysisResponse {
|
||||
success: boolean;
|
||||
data: VideoAnalysisData;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user