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>
This commit is contained in:
parent
137e6dd23c
commit
70ba2f1868
@ -507,5 +507,11 @@ export async function queryVideos(request: QueryRequest): Promise<QueryResponse>
|
|||||||
## 前端常见问题
|
## 前端常见问题
|
||||||
|
|
||||||
- **Next.js 模块错误**:清理缓存 `rm -rf .next node_modules/.cache && pnpm build`
|
- **Next.js 模块错误**:清理缓存 `rm -rf .next node_modules/.cache && pnpm build`
|
||||||
- **Ant Design Modal 文字无法复制**:添加 `styles={{ body: { userSelect: 'text' } }}`
|
- **Ant Design Modal 文字无法复制**:需要多层修复:
|
||||||
|
1. Modal: `styles={{ body: { userSelect: 'text' } }}`
|
||||||
|
2. Descriptions: 添加 `contentStyle={{ userSelect: 'text', cursor: 'text' }}`
|
||||||
|
3. globals.css: 添加 `.ant-descriptions td * { user-select: text !important; }`
|
||||||
|
- **Next.js layout.tsx 不能加 'use client'**:因为导出 metadata,需创建单独的 Provider 组件(如 AntdProvider.tsx)
|
||||||
|
- **Ant Design v6 ConfigProvider**:v6 不支持 `theme.cssVar` 和 `theme.hashed`,直接用 `<ConfigProvider locale={zhCN}>` 即可
|
||||||
|
- **前端性能优化**:使用 `useMemo` 包裹 columns、`useCallback` 包裹事件处理器、`memo` 包裹子组件
|
||||||
- **CORS 400 错误**:检查后端 `CORSMiddleware` 配置的 `allow_origins`
|
- **CORS 400 错误**:检查后端 `CORSMiddleware` 配置的 `allow_origins`
|
||||||
|
|||||||
316
backend/tests/test_yuntu_api_params.py
Normal file
316
backend/tests/test_yuntu_api_params.py
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
"""
|
||||||
|
Tests for Yuntu API Parameter Format (T-027)
|
||||||
|
|
||||||
|
根据 doc/temp 的正确格式:
|
||||||
|
1. 日期格式: YYYYMMDD (如 20251014),不是 YYYY-MM-DD
|
||||||
|
2. Cookie 头: 直接使用 auth_token 完整值
|
||||||
|
3. industry_id: 字符串格式 ["20"],不是整数
|
||||||
|
4. Cookie 获取: 随机选取任意一组 aadvid/auth_token
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, date
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class TestYuntuAPIParameterFormat:
|
||||||
|
"""验证 API 调用参数格式正确性 (T-027)"""
|
||||||
|
|
||||||
|
async def test_date_format_yyyymmdd(self):
|
||||||
|
"""日期格式必须为 YYYYMMDD,不是 YYYY-MM-DD"""
|
||||||
|
from app.services.yuntu_api import call_yuntu_api
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"status": 0, "data": {"a3_increase_cnt": "100"}}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
await call_yuntu_api(
|
||||||
|
item_id="video_001",
|
||||||
|
publish_time=datetime(2025, 10, 14),
|
||||||
|
industry_id="12",
|
||||||
|
aadvid="1648829117232140",
|
||||||
|
auth_token="sessionid=f9dfd57df6935afd1255bdc8f0dd0e4b",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
json_data = call_args.kwargs["json"]
|
||||||
|
|
||||||
|
# 关键验证:日期格式是 YYYYMMDD
|
||||||
|
assert json_data["start_date"] == "20251014", f"Expected '20251014', got '{json_data['start_date']}'"
|
||||||
|
assert json_data["end_date"] == "20251113", f"Expected '20251113', got '{json_data['end_date']}'"
|
||||||
|
|
||||||
|
async def test_cookie_header_uses_auth_token_directly(self):
|
||||||
|
"""Cookie 头应直接使用 auth_token 完整值"""
|
||||||
|
from app.services.yuntu_api import call_yuntu_api
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"status": 0, "data": {}}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
|
auth_token = "sessionid=f9dfd57df6935afd1255bdc8f0dd0e4b"
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
await call_yuntu_api(
|
||||||
|
item_id="video_001",
|
||||||
|
publish_time=datetime(2025, 10, 14),
|
||||||
|
industry_id="12",
|
||||||
|
aadvid="1648829117232140",
|
||||||
|
auth_token=auth_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
headers = call_args.kwargs["headers"]
|
||||||
|
|
||||||
|
# 关键验证:Cookie 直接使用 auth_token 完整值
|
||||||
|
assert headers["Cookie"] == auth_token, f"Expected Cookie='{auth_token}', got '{headers['Cookie']}'"
|
||||||
|
|
||||||
|
async def test_industry_id_as_string_array(self):
|
||||||
|
"""industry_id_list 应为字符串数组 ["12"],不是整数"""
|
||||||
|
from app.services.yuntu_api import call_yuntu_api
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"status": 0, "data": {}}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
await call_yuntu_api(
|
||||||
|
item_id="video_001",
|
||||||
|
publish_time=datetime(2025, 10, 14),
|
||||||
|
industry_id="12", # 字符串
|
||||||
|
aadvid="1648829117232140",
|
||||||
|
auth_token="sessionid=xxx",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
json_data = call_args.kwargs["json"]
|
||||||
|
|
||||||
|
# 关键验证:industry_id_list 是字符串数组
|
||||||
|
assert json_data["industry_id_list"] == ["12"], f"Expected ['12'], got {json_data['industry_id_list']}"
|
||||||
|
|
||||||
|
async def test_url_contains_aadvid(self):
|
||||||
|
"""URL 必须包含 aadvid 参数"""
|
||||||
|
from app.services.yuntu_api import call_yuntu_api
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"status": 0, "data": {}}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
|
aadvid = "1648829117232140"
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
await call_yuntu_api(
|
||||||
|
item_id="video_001",
|
||||||
|
publish_time=datetime(2025, 10, 14),
|
||||||
|
industry_id="12",
|
||||||
|
aadvid=aadvid,
|
||||||
|
auth_token="sessionid=xxx",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
url = call_args.args[0]
|
||||||
|
|
||||||
|
# 关键验证:URL 包含 aadvid
|
||||||
|
assert f"aadvid={aadvid}" in url, f"URL should contain 'aadvid={aadvid}', got '{url}'"
|
||||||
|
|
||||||
|
async def test_fixed_parameters(self):
|
||||||
|
"""验证固定参数值正确"""
|
||||||
|
from app.services.yuntu_api import call_yuntu_api
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"status": 0, "data": {}}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
await call_yuntu_api(
|
||||||
|
item_id="video_001",
|
||||||
|
publish_time=datetime(2025, 10, 14),
|
||||||
|
industry_id="12",
|
||||||
|
aadvid="1648829117232140",
|
||||||
|
auth_token="sessionid=xxx",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
json_data = call_args.kwargs["json"]
|
||||||
|
|
||||||
|
# 验证固定参数
|
||||||
|
assert json_data["is_my_video"] == "0"
|
||||||
|
assert json_data["object_type"] == 2
|
||||||
|
assert json_data["assist_type"] == 3
|
||||||
|
assert json_data["assist_video_type"] == 3
|
||||||
|
assert json_data["trigger_point_id_list"] == ["610000", "610300", "610301"]
|
||||||
|
|
||||||
|
async def test_end_date_is_start_plus_30_days(self):
|
||||||
|
"""end_date 应为 start_date + 30 天"""
|
||||||
|
from app.services.yuntu_api import call_yuntu_api
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"status": 0, "data": {}}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
|
# 测试日期:2025-01-15,+30天 = 2025-02-14
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
await call_yuntu_api(
|
||||||
|
item_id="video_001",
|
||||||
|
publish_time=datetime(2025, 1, 15),
|
||||||
|
industry_id="12",
|
||||||
|
aadvid="123",
|
||||||
|
auth_token="sessionid=xxx",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
json_data = call_args.kwargs["json"]
|
||||||
|
|
||||||
|
assert json_data["start_date"] == "20250115"
|
||||||
|
assert json_data["end_date"] == "20250214"
|
||||||
|
|
||||||
|
async def test_parse_a3_metrics_as_strings(self):
|
||||||
|
"""API 返回的 A3 指标是字符串类型,需正确解析"""
|
||||||
|
from app.services.yuntu_api import parse_analysis_response
|
||||||
|
|
||||||
|
# 实际 API 响应示例(A3 是字符串)
|
||||||
|
response = {
|
||||||
|
"status": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"object_id": "7560751618711457062",
|
||||||
|
"cost": 785000,
|
||||||
|
"ad_a3_increase_cnt": "36902",
|
||||||
|
"natural_a3_increase_cnt": "1652169",
|
||||||
|
"a3_increase_cnt": "1689071",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = parse_analysis_response(response)
|
||||||
|
|
||||||
|
# 解析后应转为整数
|
||||||
|
assert result["a3_increase_cnt"] == 1689071
|
||||||
|
assert result["ad_a3_increase_cnt"] == 36902
|
||||||
|
assert result["natural_a3_increase_cnt"] == 1652169
|
||||||
|
assert result["cost"] == 785000
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionPoolRandomSelection:
|
||||||
|
"""验证 Cookie 池随机选取逻辑 (T-027)"""
|
||||||
|
|
||||||
|
async def test_get_random_config(self):
|
||||||
|
"""应随机选取任意一组配置,不按 brand_id 匹配"""
|
||||||
|
from app.services.session_pool import SessionPool, CookieConfig
|
||||||
|
|
||||||
|
pool = SessionPool()
|
||||||
|
|
||||||
|
# 模拟刷新后的数据
|
||||||
|
pool._configs = [
|
||||||
|
CookieConfig(
|
||||||
|
brand_id="533661",
|
||||||
|
aadvid="1648829117232140",
|
||||||
|
auth_token="sessionid=aaa",
|
||||||
|
industry_id=20,
|
||||||
|
brand_name="Test1",
|
||||||
|
),
|
||||||
|
CookieConfig(
|
||||||
|
brand_id="10186612",
|
||||||
|
aadvid="1234567890",
|
||||||
|
auth_token="sessionid=bbb",
|
||||||
|
industry_id=30,
|
||||||
|
brand_name="Test2",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 调用随机获取
|
||||||
|
config = pool.get_random_config()
|
||||||
|
|
||||||
|
# 应返回一个有效配置
|
||||||
|
assert config is not None
|
||||||
|
assert "aadvid" in config
|
||||||
|
assert "auth_token" in config
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegrationWithRealFormat:
|
||||||
|
"""集成测试:验证完整调用流程"""
|
||||||
|
|
||||||
|
async def test_full_api_call_format(self):
|
||||||
|
"""完整验证 API 调用格式"""
|
||||||
|
from app.services.yuntu_api import call_yuntu_api
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"status": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": {
|
||||||
|
"object_id": "7560751618711457062",
|
||||||
|
"cost": 785000,
|
||||||
|
"ad_a3_increase_cnt": "36902",
|
||||||
|
"natural_a3_increase_cnt": "1652169",
|
||||||
|
"a3_increase_cnt": "1689071",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
mock_client.__aenter__.return_value = mock_client
|
||||||
|
mock_client.__aexit__.return_value = None
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||||
|
result = await call_yuntu_api(
|
||||||
|
item_id="7560751618711457062",
|
||||||
|
publish_time=datetime(2025, 10, 14),
|
||||||
|
industry_id="12",
|
||||||
|
aadvid="1648829117232140",
|
||||||
|
auth_token="sessionid=f9dfd57df6935afd1255bdc8f0dd0e4b",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证调用参数
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
url = call_args.args[0]
|
||||||
|
json_data = call_args.kwargs["json"]
|
||||||
|
headers = call_args.kwargs["headers"]
|
||||||
|
|
||||||
|
# 1. URL 包含 aadvid
|
||||||
|
assert "aadvid=1648829117232140" in url
|
||||||
|
|
||||||
|
# 2. 日期格式 YYYYMMDD
|
||||||
|
assert json_data["start_date"] == "20251014"
|
||||||
|
assert json_data["end_date"] == "20251113"
|
||||||
|
|
||||||
|
# 3. industry_id 字符串数组
|
||||||
|
assert json_data["industry_id_list"] == ["12"]
|
||||||
|
|
||||||
|
# 4. Cookie 直接使用 auth_token
|
||||||
|
assert headers["Cookie"] == "sessionid=f9dfd57df6935afd1255bdc8f0dd0e4b"
|
||||||
|
|
||||||
|
# 验证返回结果
|
||||||
|
assert result["status"] == 0
|
||||||
|
assert result["data"]["a3_increase_cnt"] == "1689071"
|
||||||
@ -25,3 +25,35 @@ body {
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ant Design Modal 内容可复制 */
|
||||||
|
.ant-modal-body {
|
||||||
|
user-select: text !important;
|
||||||
|
-webkit-user-select: text !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-body * {
|
||||||
|
user-select: text !important;
|
||||||
|
-webkit-user-select: text !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Descriptions 所有内容可复制 - Ant Design v6 兼容 */
|
||||||
|
.ant-descriptions-item-content,
|
||||||
|
.ant-descriptions-item-content *,
|
||||||
|
.ant-descriptions td,
|
||||||
|
.ant-descriptions td *,
|
||||||
|
[class*="ant-descriptions"] td,
|
||||||
|
[class*="ant-descriptions"] td * {
|
||||||
|
user-select: text !important;
|
||||||
|
-webkit-user-select: text !important;
|
||||||
|
-moz-user-select: text !important;
|
||||||
|
-ms-user-select: text !important;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保表格单元格内容可选 */
|
||||||
|
.ant-table-cell,
|
||||||
|
.ant-table-cell * {
|
||||||
|
user-select: text !important;
|
||||||
|
-webkit-user-select: text !important;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import localFont from 'next/font/local';
|
import localFont from 'next/font/local';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { Header, Footer } from '@/components';
|
import { Header, Footer, AntdProvider } from '@/components';
|
||||||
|
|
||||||
const geistSans = localFont({
|
const geistSans = localFont({
|
||||||
src: './fonts/GeistVF.woff',
|
src: './fonts/GeistVF.woff',
|
||||||
@ -27,11 +27,13 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
<div className="min-h-screen flex flex-col">
|
<AntdProvider>
|
||||||
<Header />
|
<div className="min-h-screen flex flex-col">
|
||||||
<main className="flex-1 bg-gray-50">{children}</main>
|
<Header />
|
||||||
<Footer />
|
<main className="flex-1 bg-gray-50">{children}</main>
|
||||||
</div>
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</AntdProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
12
frontend/src/components/AntdProvider.tsx
Normal file
12
frontend/src/components/AntdProvider.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
|
||||||
|
export default function AntdProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
{children}
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo, useCallback, memo } from 'react';
|
||||||
import { Table, Input, Select, Button, Card, Space, message, Modal, Descriptions, Spin } from 'antd';
|
import { Table, Input, Select, Button, Card, Space, message, Modal, Descriptions, Spin } from 'antd';
|
||||||
import { SearchOutlined, EyeOutlined } from '@ant-design/icons';
|
import { SearchOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@ -34,8 +34,8 @@ function formatInt(num: number | null | undefined): string {
|
|||||||
return Math.round(num).toLocaleString('zh-CN');
|
return Math.round(num).toLocaleString('zh-CN');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 详情弹窗组件
|
// 详情弹窗组件 - 使用 memo 避免不必要的重渲染
|
||||||
function DetailModal({
|
const DetailModal = memo(function DetailModal({
|
||||||
visible,
|
visible,
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
@ -53,18 +53,18 @@ function DetailModal({
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={900}
|
width={900}
|
||||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', userSelect: 'text' } }}
|
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', userSelect: 'text', WebkitUserSelect: 'text' } }}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 50 }}>
|
<div style={{ textAlign: 'center', padding: 50 }}>
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%', userSelect: 'text', WebkitUserSelect: 'text' } as React.CSSProperties}>
|
||||||
{/* 基础信息 */}
|
{/* 基础信息 */}
|
||||||
<Descriptions title="基础信息" bordered size="small" column={2}>
|
<Descriptions title="基础信息" bordered size="small" column={2} contentStyle={{ userSelect: 'text', cursor: 'text' }}>
|
||||||
<Descriptions.Item label="达人昵称">{data.base_info.star_nickname || '-'}</Descriptions.Item>
|
<Descriptions.Item label="达人昵称"><span style={{ userSelect: 'text' }}>{data.base_info.star_nickname || '-'}</span></Descriptions.Item>
|
||||||
<Descriptions.Item label="达人unique_id">{data.base_info.star_unique_id || '-'}</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="视频ID">{data.base_info.vid || '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="发布时间">{data.base_info.create_date || '-'}</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.hot_type || '-'}</Descriptions.Item>
|
||||||
@ -82,7 +82,7 @@ function DetailModal({
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{/* 触达指标 */}
|
{/* 触达指标 */}
|
||||||
<Descriptions title="触达指标" bordered size="small" column={4}>
|
<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.natural_play_cnt)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="加热曝光数">{formatInt(data.reach_metrics.heated_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_play_cnt)}</Descriptions.Item>
|
||||||
@ -93,14 +93,14 @@ function DetailModal({
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{/* A3指标 */}
|
{/* A3指标 */}
|
||||||
<Descriptions title="A3指标" bordered size="small" column={3}>
|
<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.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.heated_new_a3_cnt)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="自然新增A3">{formatInt(data.a3_metrics.natural_new_a3_cnt)}</Descriptions.Item>
|
<Descriptions.Item label="自然新增A3">{formatInt(data.a3_metrics.natural_new_a3_cnt)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{/* 搜索指标 */}
|
{/* 搜索指标 */}
|
||||||
<Descriptions title="搜索指标" bordered size="small" column={3}>
|
<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_uv)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="回搜次数">{formatInt(data.search_metrics.back_search_cnt)}</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_uv)}</Descriptions.Item>
|
||||||
@ -109,14 +109,14 @@ function DetailModal({
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{/* 费用指标 */}
|
{/* 费用指标 */}
|
||||||
<Descriptions title="费用指标" bordered size="small" column={3}>
|
<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.total_cost)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="预估加热费用">{formatNumber(data.cost_metrics.heated_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.Item label="预估视频采买费用">{formatNumber(data.cost_metrics.estimated_video_cost)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{/* 成本指标 */}
|
{/* 成本指标 */}
|
||||||
<Descriptions title="成本指标" bordered size="small" column={3}>
|
<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_cpm)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="预估自然CPM">{formatNumber(data.calculated_metrics.estimated_natural_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_cp_a3)}</Descriptions.Item>
|
||||||
@ -128,7 +128,7 @@ function DetailModal({
|
|||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default function VideoAnalysis() {
|
export default function VideoAnalysis() {
|
||||||
const [searchType, setSearchType] = useState<SearchType>('star_id');
|
const [searchType, setSearchType] = useState<SearchType>('star_id');
|
||||||
@ -141,7 +141,11 @@ export default function VideoAnalysis() {
|
|||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [detailData, setDetailData] = useState<VideoAnalysisData | null>(null);
|
const [detailData, setDetailData] = useState<VideoAnalysisData | null>(null);
|
||||||
|
|
||||||
const handleSearch = async () => {
|
// 详情缓存 - 避免重复请求
|
||||||
|
const [detailCache, setDetailCache] = useState<Record<string, VideoAnalysisData>>({});
|
||||||
|
|
||||||
|
// 使用 useCallback 包裹搜索处理器
|
||||||
|
const handleSearch = useCallback(async () => {
|
||||||
if (!searchValue.trim()) {
|
if (!searchValue.trim()) {
|
||||||
message.warning(`请输入${SEARCH_TYPE_OPTIONS.find(o => o.value === searchType)?.label}`);
|
message.warning(`请输入${SEARCH_TYPE_OPTIONS.find(o => o.value === searchType)?.label}`);
|
||||||
return;
|
return;
|
||||||
@ -167,9 +171,17 @@ export default function VideoAnalysis() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [searchType, searchValue]);
|
||||||
|
|
||||||
|
// 使用 useCallback 包裹详情查看处理器,带缓存逻辑
|
||||||
|
const handleViewDetail = useCallback(async (itemId: string) => {
|
||||||
|
// 检查缓存
|
||||||
|
if (detailCache[itemId]) {
|
||||||
|
setDetailData(detailCache[itemId]);
|
||||||
|
setDetailVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleViewDetail = async (itemId: string) => {
|
|
||||||
setDetailVisible(true);
|
setDetailVisible(true);
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
setDetailData(null);
|
setDetailData(null);
|
||||||
@ -178,6 +190,8 @@ export default function VideoAnalysis() {
|
|||||||
const response = await getVideoAnalysis(itemId);
|
const response = await getVideoAnalysis(itemId);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setDetailData(response.data);
|
setDetailData(response.data);
|
||||||
|
// 缓存结果
|
||||||
|
setDetailCache(prev => ({ ...prev, [itemId]: response.data }));
|
||||||
} else {
|
} else {
|
||||||
message.error(response.error || '获取详情失败');
|
message.error(response.error || '获取详情失败');
|
||||||
setDetailVisible(false);
|
setDetailVisible(false);
|
||||||
@ -188,10 +202,15 @@ export default function VideoAnalysis() {
|
|||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [detailCache]);
|
||||||
|
|
||||||
// 表格列定义
|
// 关闭详情弹窗的回调
|
||||||
const columns: ColumnsType<VideoListItem> = [
|
const handleCloseDetail = useCallback(() => {
|
||||||
|
setDetailVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 使用 useMemo 包裹表格列定义,避免每次重建
|
||||||
|
const columns: ColumnsType<VideoListItem> = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '达人昵称',
|
title: '达人昵称',
|
||||||
dataIndex: 'star_nickname',
|
dataIndex: 'star_nickname',
|
||||||
@ -322,7 +341,7 @@ export default function VideoAnalysis() {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
], [handleViewDetail]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, maxWidth: 1600, margin: '0 auto' }}>
|
<div style={{ padding: 24, maxWidth: 1600, margin: '0 auto' }}>
|
||||||
@ -357,14 +376,15 @@ export default function VideoAnalysis() {
|
|||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 结果表格 */}
|
{/* 结果表格 - 启用虚拟滚动 */}
|
||||||
<Card>
|
<Card>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={listData}
|
dataSource={listData}
|
||||||
rowKey="item_id"
|
rowKey="item_id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 1800 }}
|
virtual
|
||||||
|
scroll={{ x: 1800, y: 600 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
showQuickJumper: true,
|
showQuickJumper: true,
|
||||||
@ -379,7 +399,7 @@ export default function VideoAnalysis() {
|
|||||||
visible={detailVisible}
|
visible={detailVisible}
|
||||||
data={detailData}
|
data={detailData}
|
||||||
loading={detailLoading}
|
loading={detailLoading}
|
||||||
onClose={() => setDetailVisible(false)}
|
onClose={handleCloseDetail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,3 +3,4 @@ export { default as Footer } from './Footer';
|
|||||||
export { default as QueryForm } from './QueryForm';
|
export { default as QueryForm } from './QueryForm';
|
||||||
export { default as ResultTable } from './ResultTable';
|
export { default as ResultTable } from './ResultTable';
|
||||||
export { default as ExportButton } from './ExportButton';
|
export { default as ExportButton } from './ExportButton';
|
||||||
|
export { default as AntdProvider } from './AntdProvider';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user