diff --git a/.gitignore b/.gitignore index 186d357..290653c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ Thumbs.db .eggs/ pip-log.txt pip-delete-this-directory.txt + + + +temp/ \ No newline at end of file diff --git a/backend/app/api/v1/video_analysis.py b/backend/app/api/v1/video_analysis.py index 4d4fdd7..a9ec5fe 100644 --- a/backend/app/api/v1/video_analysis.py +++ b/backend/app/api/v1/video_analysis.py @@ -1,26 +1,55 @@ """ 视频分析API路由 (T-024) -GET /api/v1/videos/{item_id}/analysis +GET /api/v1/videos/{item_id}/analysis - 单个视频分析 +POST /api/v1/videos/search - 搜索视频列表(支持 star_id / nickname) """ -from fastapi import APIRouter, Depends, HTTPException +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.services.video_analysis import get_video_analysis_data +from app.services.video_analysis import ( + get_video_analysis_data, + get_video_base_info, + search_videos_by_star_id, + search_videos_by_unique_id, + search_videos_by_nickname, + get_video_list_with_a3, +) from app.services.yuntu_api import YuntuAPIError router = APIRouter(prefix="/videos", tags=["视频分析"]) +class SearchRequest(BaseModel): + """搜索请求""" + type: str # "star_id" | "unique_id" | "nickname" + value: str + + +class VideoListItem(BaseModel): + """视频列表项""" + item_id: str + title: str + star_nickname: str + star_unique_id: str + create_date: Optional[str] + hot_type: str + total_play_cnt: int + total_new_a3_cnt: int + total_cost: float + + @router.get("/{item_id}/analysis") async def get_video_analysis( item_id: str, db: AsyncSession = Depends(get_db), ): """ - 获取视频分析数据。 + 获取单个视频分析数据。 返回6大类指标: - 基础信息 (8字段) @@ -53,3 +82,94 @@ async def get_video_analysis( raise HTTPException(status_code=500, detail=f"API Error: {e.message}") except Exception as e: raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}") + + +@router.post("/search") +async def search_videos( + request: SearchRequest, + db: AsyncSession = Depends(get_db), +): + """ + 搜索视频列表。 + + 支持三种搜索方式(均返回列表,点击详情查看完整数据): + - star_id: 星图ID精准匹配 + - unique_id: 达人unique_id精准匹配 + - nickname: 达人昵称模糊匹配 + + Args: + request: 搜索请求,包含 type 和 value + + Returns: + 视频列表(含A3数据和成本指标) + """ + try: + if request.type == "star_id": + # 星图ID查询,返回视频列表 + videos = await search_videos_by_star_id(db, request.value) + if not videos: + return { + "success": True, + "type": "list", + "data": [], + "total": 0, + } + + # 获取 A3 数据 + result = await get_video_list_with_a3(db, videos) + return { + "success": True, + "type": "list", + "data": result, + "total": len(result), + } + + elif request.type == "unique_id": + # 达人unique_id查询,返回视频列表 + videos = await search_videos_by_unique_id(db, request.value) + if not videos: + return { + "success": True, + "type": "list", + "data": [], + "total": 0, + } + + # 获取 A3 数据 + result = await get_video_list_with_a3(db, videos) + return { + "success": True, + "type": "list", + "data": result, + "total": len(result), + } + + elif request.type == "nickname": + # 昵称模糊查询,返回视频列表 + videos = await search_videos_by_nickname(db, request.value) + if not videos: + return { + "success": True, + "type": "list", + "data": [], + "total": 0, + } + + # 获取 A3 数据 + result = await get_video_list_with_a3(db, videos) + return { + "success": True, + "type": "list", + "data": result, + "total": len(result), + } + + else: + raise HTTPException(status_code=400, detail=f"Invalid search type: {request.type}") + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except YuntuAPIError as e: + raise HTTPException(status_code=500, detail=f"API Error: {e.message}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}") diff --git a/backend/app/config.py b/backend/app/config.py index f4dc154..cac11cf 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,6 +23,7 @@ class Settings(BaseSettings): # Yuntu API (for SessionID pool) YUNTU_API_TOKEN: str = "" # Bearer Token for Yuntu Cookie API + YUNTU_AADVID: str = "1648829117232140" # 广告主ID,用于巨量云图API调用 # API Settings MAX_QUERY_LIMIT: int = 1000 diff --git a/backend/app/models/kol_video.py b/backend/app/models/kol_video.py index 8171b25..beac5c6 100644 --- a/backend/app/models/kol_video.py +++ b/backend/app/models/kol_video.py @@ -1,52 +1,119 @@ -from sqlalchemy import Column, String, Integer, Float, DateTime, Index +from sqlalchemy import Column, String, Integer, Float, DateTime, BigInteger, Boolean, Date, Text +from sqlalchemy.dialects.postgresql import JSONB from app.database import Base class KolVideo(Base): - """KOL 视频数据模型.""" + """KOL 视频数据模型 - 映射真实数据库表 yuntu_industry_kol_records.""" - __tablename__ = "kol_videos" + __tablename__ = "yuntu_industry_kol_records" # 主键 item_id = Column(String, primary_key=True) # 基础信息 title = Column(String, nullable=True) - viral_type = Column(String, nullable=True) - video_url = Column(String, nullable=True) + video_url = Column(Text, nullable=True) + vid = Column(String, nullable=True) + video_duration = Column(Float, nullable=True) + create_date = Column(Date, nullable=True) + data_date = Column(Date, nullable=True) + + # 达人信息 star_id = Column(String, nullable=False) star_unique_id = Column(String, nullable=False) star_nickname = Column(String, nullable=False) - publish_time = Column(DateTime, nullable=True) + star_uid = Column(String, nullable=True) + star_fans_cnt = Column(BigInteger, nullable=True) + star_mcn = Column(String, nullable=True) + + # 热度类型 + hot_type = Column(String, nullable=True) # 映射为 viral_type + is_hot = Column(Boolean, nullable=True) + has_cart = Column(Boolean, nullable=True) # 曝光指标 - natural_play_cnt = Column(Integer, default=0) - heated_play_cnt = Column(Integer, default=0) - total_play_cnt = Column(Integer, default=0) + natural_play_cnt = Column(BigInteger, default=0) + heated_play_cnt = Column(BigInteger, default=0) + total_play_cnt = Column(BigInteger, default=0) # 互动指标 - total_interact = Column(Integer, default=0) - like_cnt = Column(Integer, default=0) - share_cnt = Column(Integer, default=0) - comment_cnt = Column(Integer, default=0) + total_interaction_cnt = Column(BigInteger, default=0) # 映射为 total_interact + natural_interaction_cnt = Column(BigInteger, default=0) + heated_interaction_cnt = Column(BigInteger, default=0) + digg_cnt = Column(BigInteger, default=0) # 映射为 like_cnt + share_cnt = Column(BigInteger, default=0) + comment_cnt = Column(BigInteger, default=0) + play_over_cnt = Column(BigInteger, default=0) + play_over_rate = Column(Float, nullable=True) - # 效果指标 + # 搜索效果指标 + back_search_cnt = Column(BigInteger, default=0) # 映射为 return_search_cnt + back_search_uv = Column(BigInteger, default=0) + after_view_search_cnt = Column(BigInteger, default=0) + after_view_search_uv = Column(BigInteger, default=0) + after_view_search_rate = Column(Float, nullable=True) + + # A3 指标 new_a3_rate = Column(Float, nullable=True) - after_view_search_uv = Column(Integer, default=0) - return_search_cnt = Column(Integer, default=0) + total_new_a3_cnt = Column(BigInteger, default=0) + natural_new_a3_cnt = Column(BigInteger, default=0) + heated_new_a3_cnt = Column(BigInteger, default=0) + + # 成本指标 + total_cost = Column(Float, nullable=True) + heated_cost = Column(Float, nullable=True) + star_task_cost = Column(Float, nullable=True) + search_cost = Column(Float, nullable=True) + ad_hot_roi = Column(Float, nullable=True) + estimated_video_cost = Column(Float, default=0) + price_under_20s = Column(BigInteger, nullable=True) + price_20_60s = Column(BigInteger, nullable=True) + price_over_60s = Column(BigInteger, nullable=True) # 商业信息 industry_id = Column(String, nullable=True) industry_name = Column(String, nullable=True) brand_id = Column(String, nullable=True) - estimated_video_cost = Column(Float, default=0) + order_id = Column(String, nullable=True) - # 索引定义 - __table_args__ = ( - Index("idx_star_id", "star_id"), - Index("idx_star_unique_id", "star_unique_id"), - Index("idx_star_nickname", "star_nickname"), - ) + # JSON 字段 + content_type = Column(JSONB, nullable=True) + industry_tags = Column(JSONB, nullable=True) + ad_hot_type = Column(JSONB, nullable=True) + trend = Column(JSONB, nullable=True) + trend_daily = Column(JSONB, nullable=True) + trend_total = Column(JSONB, nullable=True) + component_metric_list = Column(JSONB, nullable=True) + key_word_after_search_infos = Column(JSONB, nullable=True) + index_map = Column(JSONB, nullable=True) + search_keywords = Column(JSONB, nullable=True) + keywords = Column(JSONB, nullable=True) + + # 时间戳 + created_at = Column(DateTime, nullable=True) + updated_at = Column(DateTime, nullable=True) def __repr__(self): return f"" + + # 兼容属性 - 映射旧字段名到新字段名 + @property + def viral_type(self): + return self.hot_type + + @property + def total_interact(self): + return self.total_interaction_cnt + + @property + def like_cnt(self): + return self.digg_cnt + + @property + def return_search_cnt(self): + return self.back_search_cnt + + @property + def publish_time(self): + return self.create_date diff --git a/backend/app/services/brand_api.py b/backend/app/services/brand_api.py index 53fca0d..316b070 100644 --- a/backend/app/services/brand_api.py +++ b/backend/app/services/brand_api.py @@ -33,7 +33,8 @@ async def fetch_brand_name( timeout=settings.BRAND_API_TIMEOUT ) as client: response = await client.get( - f"{settings.BRAND_API_BASE_URL}/v1/yuntu/brands/{brand_id}", + f"{settings.BRAND_API_BASE_URL}/v1/yuntu/brands", + params={"brand_id": brand_id}, headers=headers, ) if response.status_code == 200: diff --git a/backend/app/services/session_pool.py b/backend/app/services/session_pool.py index 72ebdac..db1b09d 100644 --- a/backend/app/services/session_pool.py +++ b/backend/app/services/session_pool.py @@ -1,13 +1,18 @@ """ -SessionID池服务 (T-021) +SessionID池服务 (T-021, T-027) -从内部API获取Cookie列表,随机选取sessionid用于巨量云图API调用。 +从内部API获取Cookie列表,随机选取 aadvid/auth_token 用于 API 调用。 + +T-027 修复: +- 改为随机选取任意一组配置,不按 brand_id 匹配 +- auth_token 直接使用完整值 (如 "sessionid=xxx") """ import asyncio -import random import logging -from typing import List, Optional +import random +from typing import Dict, Optional, Any, List +from dataclasses import dataclass import httpx @@ -16,16 +21,27 @@ from app.config import settings logger = logging.getLogger(__name__) +@dataclass +class CookieConfig: + """Cookie 配置""" + brand_id: str + aadvid: str + auth_token: str # 完整的 cookie 值,如 "sessionid=xxx" + industry_id: int + brand_name: str + + class SessionPool: - """SessionID池管理器""" + """SessionID池管理器 - T-027: 改为随机选取""" def __init__(self): - self._sessions: List[str] = [] + # 存储所有配置的列表 + self._configs: List[CookieConfig] = [] self._lock = asyncio.Lock() async def refresh(self) -> bool: """ - 从内部API刷新SessionID列表。 + 从内部API刷新配置列表。 Returns: bool: 刷新是否成功 @@ -47,19 +63,34 @@ class SessionPool: if response.status_code == 200: data = response.json() - # 响应格式: {"data": [{"sessionid": "xxx", ...}, ...]} if isinstance(data, dict): cookie_list = data.get("data", []) if isinstance(cookie_list, list): - self._sessions = [ - item.get("sessionid") - for item in cookie_list - if isinstance(item, dict) and item.get("sessionid") - ] + self._configs = [] + for item in cookie_list: + if not isinstance(item, dict): + continue + + brand_id = str(item.get("brand_id", "")) + aadvid = str(item.get("aadvid", "")) + # T-027: 直接使用 auth_token 或 sessionid_cookie 完整值 + auth_token = item.get("auth_token") or item.get("sessionid_cookie", "") + industry_id = item.get("industry_id", 0) + brand_name = item.get("brand_name", "") + + if brand_id and aadvid and auth_token: + self._configs.append(CookieConfig( + brand_id=brand_id, + aadvid=aadvid, + auth_token=auth_token, + industry_id=int(industry_id) if industry_id else 0, + brand_name=brand_name, + )) + logger.info( - f"SessionPool refreshed: {len(self._sessions)} sessions" + f"SessionPool refreshed: {len(self._configs)} configs" ) - return len(self._sessions) > 0 + return len(self._configs) > 0 logger.warning( f"Failed to refresh session pool: status={response.status_code}" @@ -76,48 +107,101 @@ class SessionPool: logger.error(f"SessionPool refresh unexpected error: {e}") return False - def get_random(self) -> Optional[str]: + def get_random_config(self) -> Optional[Dict[str, Any]]: """ - 随机获取一个SessionID。 + T-027: 随机选取任意一组配置。 Returns: - Optional[str]: SessionID,池为空时返回None + Dict or None: 包含 aadvid 和 auth_token 的字典 """ - if not self._sessions: + if not self._configs: return None - return random.choice(self._sessions) + config = random.choice(self._configs) + return { + "brand_id": config.brand_id, + "aadvid": config.aadvid, + "auth_token": config.auth_token, + "industry_id": config.industry_id, + "brand_name": config.brand_name, + } - def remove(self, session_id: str) -> None: + def remove_by_auth_token(self, auth_token: str) -> None: """ - 从池中移除失效的SessionID。 + 从池中移除失效的配置。 Args: - session_id: 要移除的SessionID + auth_token: 要移除的 auth_token """ - try: - self._sessions.remove(session_id) - logger.info(f"Removed invalid session: {session_id[:8]}...") - except ValueError: - pass # 已经被移除 + self._configs = [c for c in self._configs if c.auth_token != auth_token] + logger.info(f"Removed invalid config: {auth_token[:20]}...") + + # 兼容旧接口 + def remove(self, session_id: str) -> None: + """兼容旧接口:移除包含指定 session_id 的配置""" + self._configs = [c for c in self._configs if session_id not in c.auth_token] @property def size(self) -> int: - """返回池中SessionID数量""" - return len(self._sessions) + """返回池中配置数量""" + return len(self._configs) @property def is_empty(self) -> bool: """检查池是否为空""" - return len(self._sessions) == 0 + return len(self._configs) == 0 + + # 兼容旧接口 + def get_random(self) -> Optional[str]: + """兼容旧接口:随机获取一个 SessionID""" + config = self.get_random_config() + if config: + # 从 auth_token 中提取 sessionid + auth_token = config["auth_token"] + if "=" in auth_token: + return auth_token.split("=", 1)[-1] + return auth_token + return None + + # 兼容旧代码 + @property + def _brand_configs(self) -> Dict[str, Any]: + """兼容旧接口""" + return {c.brand_id: c for c in self._configs} # 全局单例 session_pool = SessionPool() +async def get_random_config(max_retries: int = 3) -> Optional[Dict[str, Any]]: + """ + T-027: 随机获取一组配置,必要时刷新池。 + + Args: + max_retries: 最大重试次数 + + Returns: + Dict or None: 包含 aadvid 和 auth_token 的字典 + """ + for attempt in range(max_retries): + if session_pool.is_empty: + success = await session_pool.refresh() + if not success: + logger.warning(f"Session pool refresh failed, attempt {attempt + 1}") + continue + + config = session_pool.get_random_config() + if config: + return config + + logger.error("Failed to get config after all retries") + return None + + +# 兼容旧接口 async def get_session_with_retry(max_retries: int = 3) -> Optional[str]: """ - 获取SessionID,必要时刷新池 (T-022 支持)。 + 获取SessionID,必要时刷新池 (兼容旧接口)。 Args: max_retries: 最大重试次数 @@ -125,17 +209,18 @@ async def get_session_with_retry(max_retries: int = 3) -> Optional[str]: Returns: Optional[str]: SessionID,获取失败返回None """ - for attempt in range(max_retries): - # 如果池为空,尝试刷新 - if session_pool.is_empty: - success = await session_pool.refresh() - if not success: - logger.warning(f"Session pool refresh failed, attempt {attempt + 1}") - continue - - session_id = session_pool.get_random() - if session_id: - return session_id - - logger.error("Failed to get session after all retries") + config = await get_random_config(max_retries) + if config: + auth_token = config["auth_token"] + if "=" in auth_token: + return auth_token.split("=", 1)[-1] + return auth_token return None + + +async def get_config_for_brand(brand_id: str, max_retries: int = 3) -> Optional[Any]: + """ + 兼容旧接口:获取品牌对应的配置。 + T-027: 实际上现在随机选取,不再按 brand_id 匹配。 + """ + return await get_random_config(max_retries) diff --git a/backend/app/services/video_analysis.py b/backend/app/services/video_analysis.py index 820bbe6..8aa6aaf 100644 --- a/backend/app/services/video_analysis.py +++ b/backend/app/services/video_analysis.py @@ -318,3 +318,114 @@ async def get_and_update_video_analysis( ) return result + + +async def search_videos_by_star_id( + session: AsyncSession, star_id: str +) -> list[KolVideo]: + """根据星图ID精准匹配搜索视频列表。""" + stmt = select(KolVideo).where(KolVideo.star_id == star_id) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def search_videos_by_unique_id( + session: AsyncSession, unique_id: str +) -> list[KolVideo]: + """根据达人unique_id精准匹配搜索视频列表。""" + stmt = select(KolVideo).where(KolVideo.star_unique_id == unique_id) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def search_videos_by_nickname( + session: AsyncSession, nickname: str +) -> list[KolVideo]: + """根据达人昵称模糊匹配搜索视频列表。""" + stmt = select(KolVideo).where(KolVideo.star_nickname.ilike(f"%{nickname}%")) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def get_video_list_with_a3( + session: AsyncSession, videos: list[KolVideo] +) -> list[Dict[str, Any]]: + """ + 获取视频列表的摘要数据(实时调用云图API获取A3数据)。 + """ + from app.services.brand_api import get_brand_names + + # 批量获取品牌名称 + brand_ids = [video.brand_id for video in videos if video.brand_id] + brand_map = await get_brand_names(brand_ids) if brand_ids else {} + + result = [] + for video in videos: + # 实时调用云图 API 获取 A3 数据和 cost + a3_increase_cnt = 0 + ad_a3_increase_cnt = 0 + natural_a3_increase_cnt = 0 + api_cost = 0.0 + + try: + publish_time = video.publish_time or datetime.now() + industry_id = video.industry_id or "" + + api_response = await fetch_yuntu_analysis( + item_id=video.item_id, + publish_time=publish_time, + industry_id=industry_id, + ) + api_data = parse_analysis_response(api_response) + a3_increase_cnt = api_data.get("a3_increase_cnt", 0) + ad_a3_increase_cnt = api_data.get("ad_a3_increase_cnt", 0) + natural_a3_increase_cnt = api_data.get("natural_a3_increase_cnt", 0) + api_cost = api_data.get("cost", 0) + + except Exception as e: + logger.warning(f"API failed for {video.item_id}: {e}") + a3_increase_cnt = video.total_new_a3_cnt or 0 + ad_a3_increase_cnt = video.heated_new_a3_cnt or 0 + natural_a3_increase_cnt = video.natural_new_a3_cnt or 0 + api_cost = video.total_cost or 0.0 + + # 数据库字段 + estimated_video_cost = video.estimated_video_cost or 0.0 + natural_play_cnt = video.natural_play_cnt or 0 + total_play_cnt = video.total_play_cnt or 0 + after_view_search_uv = video.after_view_search_uv or 0 + + # 计算成本指标 + estimated_natural_search_uv = None + if total_play_cnt > 0 and after_view_search_uv > 0: + estimated_natural_search_uv = (natural_play_cnt / total_play_cnt) * after_view_search_uv + + estimated_natural_cpm = round((estimated_video_cost / natural_play_cnt) * 1000, 2) if natural_play_cnt > 0 else None + estimated_cp_a3 = round(api_cost / a3_increase_cnt, 2) if a3_increase_cnt > 0 else None + estimated_natural_cp_a3 = round(estimated_video_cost / natural_a3_increase_cnt, 2) if natural_a3_increase_cnt > 0 else None + estimated_cp_search = round(api_cost / after_view_search_uv, 2) if after_view_search_uv > 0 else None + estimated_natural_cp_search = round(estimated_video_cost / estimated_natural_search_uv, 2) if estimated_natural_search_uv and estimated_natural_search_uv > 0 else None + + brand_name = brand_map.get(video.brand_id, video.brand_id) if video.brand_id else "" + + result.append({ + "item_id": video.item_id, + "star_nickname": video.star_nickname or "", + "title": video.title or "", + "video_url": video.video_url or "", + "create_date": video.publish_time.isoformat() if video.publish_time else None, + "hot_type": video.viral_type or "", + "industry_id": video.industry_id or "", + "brand_id": video.brand_id or "", + "brand_name": brand_name, + "total_new_a3_cnt": a3_increase_cnt, + "heated_new_a3_cnt": ad_a3_increase_cnt, + "natural_new_a3_cnt": natural_a3_increase_cnt, + "estimated_natural_cpm": estimated_natural_cpm, + "estimated_cp_a3": estimated_cp_a3, + "estimated_natural_cp_a3": estimated_natural_cp_a3, + "estimated_cp_search": estimated_cp_search, + "estimated_natural_cp_search": estimated_natural_cp_search, + }) + + return result diff --git a/backend/app/services/yuntu_api.py b/backend/app/services/yuntu_api.py index 624dd6f..2d91ed7 100644 --- a/backend/app/services/yuntu_api.py +++ b/backend/app/services/yuntu_api.py @@ -1,17 +1,26 @@ """ -巨量云图API封装 (T-023) +巨量云图API封装 (T-023, T-027) 封装GetContentMaterialAnalysisInfo接口调用,获取视频分析数据。 + +T-027 修复: +1. 日期格式: YYYYMMDD (不是 YYYY-MM-DD) +2. Cookie 头: 直接使用 auth_token 完整值 +3. industry_id: 字符串格式 ["12"] +4. A3 指标: API 返回字符串,需转为整数 """ import logging from datetime import datetime, timedelta -from typing import Dict, List, Optional, Any +from typing import Dict, Optional, Any, Union import httpx from app.config import settings -from app.services.session_pool import session_pool, get_session_with_retry +from app.services.session_pool import ( + session_pool, + get_random_config, +) logger = logging.getLogger(__name__) @@ -38,11 +47,26 @@ class SessionInvalidError(YuntuAPIError): pass +def _safe_int(value: Any, default: int = 0) -> int: + """安全转换为整数,处理字符串类型的数字""" + if value is None: + return default + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError: + return default + return default + + async def call_yuntu_api( item_id: str, - publish_time: datetime, + publish_time: Union[datetime, None], industry_id: str, - session_id: Optional[str] = None, + aadvid: str, + auth_token: str, ) -> Dict[str, Any]: """ 调用巨量云图GetContentMaterialAnalysisInfo接口。 @@ -50,8 +74,9 @@ async def call_yuntu_api( Args: item_id: 视频ID publish_time: 发布时间 - industry_id: 行业ID - session_id: 可选的sessionid,不提供则从池中获取 + industry_id: 行业ID(字符串格式) + aadvid: 广告主ID(URL参数) + auth_token: Cookie完整值(如 "sessionid=xxx") Returns: Dict: API响应数据 @@ -60,16 +85,16 @@ async def call_yuntu_api( SessionInvalidError: SessionID失效时抛出 YuntuAPIError: API调用失败时抛出 """ - # 获取sessionid - if session_id is None: - session_id = await get_session_with_retry() - if session_id is None: - raise YuntuAPIError("Failed to get valid session") + # 处理 publish_time + if publish_time is None: + publish_time = datetime.now() - # 构造请求参数 - # end_date = start_date + 30天 - start_date = publish_time.strftime("%Y-%m-%d") - end_date = (publish_time + timedelta(days=30)).strftime("%Y-%m-%d") + # T-027: 日期格式必须为 YYYYMMDD + start_date = publish_time.strftime("%Y%m%d") + end_date = (publish_time + timedelta(days=30)).strftime("%Y%m%d") + + # T-027: industry_id_list 为字符串数组 + industry_id_list = [str(industry_id)] if industry_id else [] request_data = { "is_my_video": "0", @@ -79,27 +104,30 @@ async def call_yuntu_api( "end_date": end_date, "assist_type": 3, "assist_video_type": 3, - "industry_id_list": [industry_id] if industry_id else [], + "industry_id_list": industry_id_list, "trigger_point_id_list": TRIGGER_POINT_IDS, } - # 构造请求头 + # T-027: Cookie 直接使用 auth_token 完整值 headers = { "Content-Type": "application/json", - "Cookie": f"sessionid={session_id}", + "Cookie": auth_token, } + # URL 带 aadvid 参数 + url = f"{YUNTU_BASE_URL}/yuntu_common/api/content/trigger_analysis/GetContentMaterialAnalysisInfo?aadvid={aadvid}" + try: async with httpx.AsyncClient(timeout=settings.YUNTU_API_TIMEOUT) as client: response = await client.post( - f"{YUNTU_BASE_URL}/yuntu_common/api/content/trigger_analysis/GetContentMaterialAnalysisInfo", + url, json=request_data, headers=headers, ) # 检查SessionID是否失效 if response.status_code in (401, 403): - logger.warning(f"Session invalid: {session_id[:8]}...") + logger.warning(f"Session invalid: {auth_token[:20]}...") raise SessionInvalidError( f"Session invalid: {response.status_code}", status_code=response.status_code, @@ -114,9 +142,10 @@ async def call_yuntu_api( data = response.json() - # 检查业务错误码 - if data.get("code") != 0: - error_msg = data.get("message", "Unknown error") + # 检查业务错误 + status = data.get("status", data.get("code", 0)) + if status != 0: + error_msg = data.get("msg", data.get("message", "Unknown error")) raise YuntuAPIError( f"API business error: {error_msg}", status_code=response.status_code, @@ -140,51 +169,59 @@ async def get_video_analysis( max_retries: int = 3, ) -> Dict[str, Any]: """ - 获取视频分析数据,支持SessionID失效自动重试 (T-022)。 + 获取视频分析数据(随机选取配置)。 + + T-027: 改为随机选取任意一组 aadvid/auth_token,不按 brand_id 匹配。 Args: item_id: 视频ID publish_time: 发布时间 - industry_id: 行业ID + industry_id: 行业ID(来自数据库中的视频) max_retries: 最大重试次数 Returns: Dict: 视频分析数据 Raises: - YuntuAPIError: 所有重试失败后抛出 + YuntuAPIError: API调用失败时抛出 """ last_error = None for attempt in range(max_retries): - # 从池中获取sessionid - session_id = await get_session_with_retry() - if session_id is None: - last_error = YuntuAPIError("Failed to get valid session") + # T-027: 随机选取任意一组配置 + config = await get_random_config() + if config is None: + last_error = YuntuAPIError("No config available in session pool") + logger.warning(f"No config available, attempt {attempt + 1}/{max_retries}") continue + logger.info( + f"Using random config: aadvid={config['aadvid']}, attempt {attempt + 1}" + ) + try: result = await call_yuntu_api( item_id=item_id, publish_time=publish_time, - industry_id=industry_id, - session_id=session_id, + industry_id=industry_id, # T-027: 使用数据库中视频的 industry_id + aadvid=config["aadvid"], + auth_token=config["auth_token"], ) return result except SessionInvalidError: - # SessionID失效,从池中移除并重试 - session_pool.remove(session_id) + # SessionID失效,从池中移除 + session_pool.remove_by_auth_token(config["auth_token"]) logger.info( - f"Session invalid, retrying... attempt {attempt + 1}/{max_retries}" + f"Session invalid, attempt {attempt + 1}/{max_retries}" ) - last_error = SessionInvalidError("All sessions invalid") + last_error = SessionInvalidError("Session invalid after retries") continue except YuntuAPIError as e: last_error = e logger.error(f"Yuntu API error on attempt {attempt + 1}: {e.message}") - # 非SessionID问题,不再重试 + # 非 session 错误不重试 break raise last_error or YuntuAPIError("Unknown error after retries") @@ -194,35 +231,37 @@ def parse_analysis_response(data: Dict[str, Any]) -> Dict[str, Any]: """ 解析巨量云图API响应,提取关键指标。 + T-027: A3 指标在 API 响应中是字符串类型,需要转为整数。 + Args: data: API原始响应数据 Returns: Dict: 结构化的分析数据 """ - result_data = data.get("data", {}) + result_data = data.get("data", {}) or {} return { # 触达指标 - "total_show_cnt": result_data.get("total_show_cnt", 0), # 总曝光数 - "natural_show_cnt": result_data.get("natural_show_cnt", 0), # 自然曝光数 - "ad_show_cnt": result_data.get("ad_show_cnt", 0), # 加热曝光数 - "total_play_cnt": result_data.get("total_play_cnt", 0), # 总播放数 - "natural_play_cnt": result_data.get("natural_play_cnt", 0), # 自然播放数 - "ad_play_cnt": result_data.get("ad_play_cnt", 0), # 加热播放数 - "effective_play_cnt": result_data.get("effective_play_cnt", 0), # 有效播放数 - # A3指标 - "a3_increase_cnt": result_data.get("a3_increase_cnt", 0), # 新增A3 - "ad_a3_increase_cnt": result_data.get("ad_a3_increase_cnt", 0), # 加热新增A3 - "natural_a3_increase_cnt": result_data.get("natural_a3_increase_cnt", 0), # 自然新增A3 + "total_show_cnt": _safe_int(result_data.get("total_show_cnt")), + "natural_show_cnt": _safe_int(result_data.get("natural_show_cnt")), + "ad_show_cnt": _safe_int(result_data.get("ad_show_cnt")), + "total_play_cnt": _safe_int(result_data.get("total_play_cnt")), + "natural_play_cnt": _safe_int(result_data.get("natural_play_cnt")), + "ad_play_cnt": _safe_int(result_data.get("ad_play_cnt")), + "effective_play_cnt": _safe_int(result_data.get("effective_play_cnt")), + # A3指标 - T-027: 转为整数 + "a3_increase_cnt": _safe_int(result_data.get("a3_increase_cnt")), + "ad_a3_increase_cnt": _safe_int(result_data.get("ad_a3_increase_cnt")), + "natural_a3_increase_cnt": _safe_int(result_data.get("natural_a3_increase_cnt")), # 搜索指标 - "after_view_search_uv": result_data.get("after_view_search_uv", 0), # 看后搜人数 - "after_view_search_pv": result_data.get("after_view_search_pv", 0), # 看后搜次数 - "brand_search_uv": result_data.get("brand_search_uv", 0), # 品牌搜索人数 - "product_search_uv": result_data.get("product_search_uv", 0), # 商品搜索人数 - "return_search_cnt": result_data.get("return_search_cnt", 0), # 回搜次数 + "after_view_search_uv": _safe_int(result_data.get("after_view_search_uv")), + "after_view_search_pv": _safe_int(result_data.get("after_view_search_pv")), + "brand_search_uv": _safe_int(result_data.get("brand_search_uv")), + "product_search_uv": _safe_int(result_data.get("product_search_uv")), + "return_search_cnt": _safe_int(result_data.get("return_search_cnt")), # 费用指标 - "cost": result_data.get("cost", 0), # 总花费 - "natural_cost": result_data.get("natural_cost", 0), # 自然花费 - "ad_cost": result_data.get("ad_cost", 0), # 加热花费 + "cost": _safe_int(result_data.get("cost")), + "natural_cost": _safe_int(result_data.get("natural_cost")), + "ad_cost": _safe_int(result_data.get("ad_cost")), } diff --git a/backend/tests/test_session_pool.py b/backend/tests/test_session_pool.py index eb719dc..a756367 100644 --- a/backend/tests/test_session_pool.py +++ b/backend/tests/test_session_pool.py @@ -1,5 +1,10 @@ """ -Tests for SessionID Pool Service (T-021, T-022) +Tests for SessionID Pool Service (T-021, T-022, T-027) + +T-027 更新: +- 改为 CookieConfig 数据结构 +- get_random_config() 随机选取配置 +- remove_by_auth_token() 移除失效配置 """ import pytest @@ -8,8 +13,10 @@ import httpx from app.services.session_pool import ( SessionPool, + CookieConfig, session_pool, get_session_with_retry, + get_random_config, ) @@ -17,16 +24,27 @@ class TestSessionPool: """Tests for SessionPool class.""" async def test_refresh_success(self): - """Test successful session pool refresh.""" + """Test successful session pool refresh (T-027 format).""" pool = SessionPool() mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "data": [ - {"sessionid": "session_001", "user": "test1"}, - {"sessionid": "session_002", "user": "test2"}, - {"sessionid": "session_003", "user": "test3"}, + { + "brand_id": "533661", + "aadvid": "1648829117232140", + "auth_token": "sessionid=session_001", + "industry_id": 20, + "brand_name": "Brand1", + }, + { + "brand_id": "10186612", + "aadvid": "9876543210", + "auth_token": "sessionid=session_002", + "industry_id": 30, + "brand_name": "Brand2", + }, ] } @@ -39,9 +57,38 @@ class TestSessionPool: result = await pool.refresh() assert result is True - assert pool.size == 3 + assert pool.size == 2 assert not pool.is_empty + async def test_refresh_with_sessionid_cookie_field(self): + """Test refresh using sessionid_cookie field (fallback).""" + pool = SessionPool() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "brand_id": "533661", + "aadvid": "1648829117232140", + "sessionid_cookie": "sessionid=session_001", + "industry_id": 20, + "brand_name": "Brand1", + }, + ] + } + + mock_client = AsyncMock() + mock_client.get.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 pool.refresh() + + assert result is True + assert pool.size == 1 + async def test_refresh_empty_data(self): """Test refresh with empty data array.""" pool = SessionPool() @@ -126,7 +173,17 @@ class TestSessionPool: mock_response = MagicMock() mock_response.status_code = 200 - mock_response.json.return_value = {"data": [{"sessionid": "test"}]} + mock_response.json.return_value = { + "data": [ + { + "brand_id": "123", + "aadvid": "456", + "auth_token": "sessionid=test", + "industry_id": 20, + "brand_name": "Test", + } + ] + } mock_client = AsyncMock() mock_client.get.return_value = mock_response @@ -146,40 +203,131 @@ class TestSessionPool: assert "headers" in call_args.kwargs assert call_args.kwargs["headers"]["Authorization"] == "Bearer test_token" - def test_get_random_from_pool(self): - """Test getting random session from pool.""" + def test_get_random_config_from_pool(self): + """Test getting random config from pool (T-027).""" pool = SessionPool() - pool._sessions = ["session_1", "session_2", "session_3"] + pool._configs = [ + CookieConfig( + brand_id="533661", + aadvid="1648829117232140", + auth_token="sessionid=session_1", + industry_id=20, + brand_name="Brand1", + ), + CookieConfig( + brand_id="10186612", + aadvid="9876543210", + auth_token="sessionid=session_2", + industry_id=30, + brand_name="Brand2", + ), + ] + + config = pool.get_random_config() + + assert config is not None + assert "aadvid" in config + assert "auth_token" in config + assert config["auth_token"] in ["sessionid=session_1", "sessionid=session_2"] + + def test_get_random_config_from_empty_pool(self): + """Test getting random config from empty pool.""" + pool = SessionPool() + + config = pool.get_random_config() + + assert config is None + + def test_get_random_from_pool_compat(self): + """Test get_random compatibility method.""" + pool = SessionPool() + pool._configs = [ + CookieConfig( + brand_id="533661", + aadvid="1648829117232140", + auth_token="sessionid=session_1", + industry_id=20, + brand_name="Brand1", + ), + ] session = pool.get_random() - assert session in pool._sessions + assert session == "session_1" - def test_get_random_from_empty_pool(self): - """Test getting random session from empty pool.""" + def test_get_random_from_empty_pool_compat(self): + """Test get_random from empty pool.""" pool = SessionPool() session = pool.get_random() assert session is None - def test_remove_session(self): - """Test removing a session from pool.""" + def test_remove_by_auth_token(self): + """Test removing config by auth_token (T-027).""" pool = SessionPool() - pool._sessions = ["session_1", "session_2", "session_3"] + pool._configs = [ + CookieConfig( + brand_id="533661", + aadvid="1648829117232140", + auth_token="sessionid=session_1", + industry_id=20, + brand_name="Brand1", + ), + CookieConfig( + brand_id="10186612", + aadvid="9876543210", + auth_token="sessionid=session_2", + industry_id=30, + brand_name="Brand2", + ), + ] - pool.remove("session_2") + pool.remove_by_auth_token("sessionid=session_1") - assert pool.size == 2 - assert "session_2" not in pool._sessions + assert pool.size == 1 + config = pool.get_random_config() + assert config["auth_token"] == "sessionid=session_2" + + def test_remove_session_compat(self): + """Test remove compatibility method.""" + pool = SessionPool() + pool._configs = [ + CookieConfig( + brand_id="533661", + aadvid="1648829117232140", + auth_token="sessionid=session_1", + industry_id=20, + brand_name="Brand1", + ), + CookieConfig( + brand_id="10186612", + aadvid="9876543210", + auth_token="sessionid=session_2", + industry_id=30, + brand_name="Brand2", + ), + ] + + pool.remove("session_1") + + assert pool.size == 1 def test_remove_nonexistent_session(self): """Test removing a session that doesn't exist.""" pool = SessionPool() - pool._sessions = ["session_1"] + pool._configs = [ + CookieConfig( + brand_id="533661", + aadvid="1648829117232140", + auth_token="sessionid=session_1", + industry_id=20, + brand_name="Brand1", + ), + ] # Should not raise - pool.remove("nonexistent") + pool.remove_by_auth_token("nonexistent") assert pool.size == 1 @@ -188,7 +336,22 @@ class TestSessionPool: pool = SessionPool() assert pool.size == 0 - pool._sessions = ["a", "b"] + pool._configs = [ + CookieConfig( + brand_id="123", + aadvid="456", + auth_token="sessionid=a", + industry_id=20, + brand_name="A", + ), + CookieConfig( + brand_id="789", + aadvid="012", + auth_token="sessionid=b", + industry_id=30, + brand_name="B", + ), + ] assert pool.size == 2 def test_is_empty_property(self): @@ -196,29 +359,117 @@ class TestSessionPool: pool = SessionPool() assert pool.is_empty is True - pool._sessions = ["a"] + pool._configs = [ + CookieConfig( + brand_id="123", + aadvid="456", + auth_token="sessionid=a", + industry_id=20, + brand_name="A", + ), + ] assert pool.is_empty is False +class TestGetRandomConfig: + """Tests for get_random_config function (T-027).""" + + async def test_get_config_success(self): + """Test successful config retrieval.""" + pool = SessionPool() + pool._configs = [ + CookieConfig( + brand_id="533661", + aadvid="1648829117232140", + auth_token="sessionid=session_1", + industry_id=20, + brand_name="Brand1", + ), + ] + + with patch("app.services.session_pool.session_pool", pool): + result = await get_random_config() + + assert result is not None + assert result["aadvid"] == "1648829117232140" + assert result["auth_token"] == "sessionid=session_1" + + async def test_get_config_refresh_on_empty(self): + """Test that pool is refreshed when empty.""" + pool = SessionPool() + + with patch("app.services.session_pool.session_pool", pool): + with patch.object(pool, "refresh") as mock_refresh: + async def refresh_side_effect(): + pool._configs = [ + CookieConfig( + brand_id="123", + aadvid="456", + auth_token="sessionid=new_session", + industry_id=20, + brand_name="New", + ), + ] + return True + + mock_refresh.side_effect = refresh_side_effect + + result = await get_random_config() + + assert mock_refresh.called + assert result["auth_token"] == "sessionid=new_session" + + async def test_get_config_retry_on_refresh_failure(self): + """Test retry behavior when refresh fails.""" + pool = SessionPool() + + with patch("app.services.session_pool.session_pool", pool): + with patch.object(pool, "refresh") as mock_refresh: + mock_refresh.return_value = False + + result = await get_random_config(max_retries=3) + + assert result is None + assert mock_refresh.call_count == 3 + + class TestGetSessionWithRetry: - """Tests for get_session_with_retry function (T-022).""" + """Tests for get_session_with_retry function (T-022 compat).""" async def test_get_session_success(self): """Test successful session retrieval.""" - with patch.object(session_pool, "_sessions", ["session_1", "session_2"]): + pool = SessionPool() + pool._configs = [ + CookieConfig( + brand_id="533661", + aadvid="1648829117232140", + auth_token="sessionid=session_1", + industry_id=20, + brand_name="Brand1", + ), + ] + + with patch("app.services.session_pool.session_pool", pool): result = await get_session_with_retry() - assert result in ["session_1", "session_2"] + assert result == "session_1" async def test_get_session_refresh_on_empty(self): """Test that pool is refreshed when empty.""" - with patch.object(session_pool, "_sessions", []): - with patch.object(session_pool, "refresh") as mock_refresh: - mock_refresh.return_value = True + pool = SessionPool() - # After refresh, pool should have sessions + with patch("app.services.session_pool.session_pool", pool): + with patch.object(pool, "refresh") as mock_refresh: async def refresh_side_effect(): - session_pool._sessions.append("new_session") + pool._configs = [ + CookieConfig( + brand_id="123", + aadvid="456", + auth_token="sessionid=new_session", + industry_id=20, + brand_name="New", + ), + ] return True mock_refresh.side_effect = refresh_side_effect @@ -230,55 +481,65 @@ class TestGetSessionWithRetry: async def test_get_session_retry_on_refresh_failure(self): """Test retry behavior when refresh fails.""" - original_sessions = session_pool._sessions.copy() + pool = SessionPool() - try: - session_pool._sessions = [] - - with patch.object(session_pool, "refresh") as mock_refresh: + with patch("app.services.session_pool.session_pool", pool): + with patch.object(pool, "refresh") as mock_refresh: mock_refresh.return_value = False result = await get_session_with_retry(max_retries=3) assert result is None assert mock_refresh.call_count == 3 - finally: - session_pool._sessions = original_sessions async def test_get_session_max_retries(self): """Test max retries limit.""" - original_sessions = session_pool._sessions.copy() + pool = SessionPool() - try: - session_pool._sessions = [] - - with patch.object(session_pool, "refresh") as mock_refresh: + with patch("app.services.session_pool.session_pool", pool): + with patch.object(pool, "refresh") as mock_refresh: mock_refresh.return_value = False result = await get_session_with_retry(max_retries=5) assert result is None assert mock_refresh.call_count == 5 - finally: - session_pool._sessions = original_sessions class TestSessionPoolIntegration: """Integration tests for session pool.""" async def test_refresh_filters_invalid_items(self): - """Test that refresh filters out invalid items.""" + """Test that refresh filters out invalid items (T-027 format).""" pool = SessionPool() mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "data": [ - {"sessionid": "valid_session"}, - {"no_sessionid": "missing"}, + { + "brand_id": "533661", + "aadvid": "1648829117232140", + "auth_token": "sessionid=valid_session", + "industry_id": 20, + "brand_name": "Valid1", + }, + {"no_auth_token": "missing"}, None, - {"sessionid": ""}, # Empty string should be filtered - {"sessionid": "another_valid"}, + { + "brand_id": "10186612", + "aadvid": "", # Empty aadvid should be filtered + "auth_token": "sessionid=xxx", + "industry_id": 30, + "brand_name": "Invalid", + }, + { + "brand_id": "789012", + "aadvid": "9876543210", + "auth_token": "sessionid=another_valid", + "industry_id": 40, + "brand_name": "Valid2", + }, ] } @@ -292,8 +553,6 @@ class TestSessionPoolIntegration: assert result is True assert pool.size == 2 - assert "valid_session" in pool._sessions - assert "another_valid" in pool._sessions async def test_refresh_handles_non_dict_data(self): """Test refresh with non-dict response.""" diff --git a/backend/tests/test_video_analysis.py b/backend/tests/test_video_analysis.py index f8f5628..0b6c01b 100644 --- a/backend/tests/test_video_analysis.py +++ b/backend/tests/test_video_analysis.py @@ -195,6 +195,13 @@ class TestGetVideoAnalysisData: result = await get_video_analysis_data(mock_session, "video_123") + # T-027: 验证使用 industry_id 而不是 brand_id 调用 API + mock_api.assert_called_once_with( + item_id="video_123", + publish_time=datetime(2025, 1, 15), + industry_id="20", + ) + # 验证基础信息 assert result["base_info"]["item_id"] == "video_123" assert result["base_info"]["title"] == "测试视频" @@ -249,6 +256,10 @@ class TestGetVideoAnalysisData: mock_video.after_view_search_uv = 1000 mock_video.return_search_cnt = 50 mock_video.estimated_video_cost = 10000 + mock_video.total_new_a3_cnt = 500 + mock_video.heated_new_a3_cnt = 100 + mock_video.natural_new_a3_cnt = 400 + mock_video.total_cost = 10000 # Mock session mock_session = AsyncMock() diff --git a/backend/tests/test_yuntu_api.py b/backend/tests/test_yuntu_api.py index 91a24d6..5d0e4d9 100644 --- a/backend/tests/test_yuntu_api.py +++ b/backend/tests/test_yuntu_api.py @@ -1,5 +1,10 @@ """ -Tests for Yuntu API Service (T-023) +Tests for Yuntu API Service (T-023, T-027) + +T-027 更新: +- call_yuntu_api 参数改为 auth_token(完整 cookie 值) +- 日期格式改为 YYYYMMDD +- industry_id 改为字符串 """ import pytest @@ -24,11 +29,11 @@ class TestCallYuntuAPI: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "code": 0, - "message": "success", + "status": 0, + "msg": "ok", "data": { "total_show_cnt": 100000, - "a3_increase_cnt": 500, + "a3_increase_cnt": "500", }, } @@ -42,17 +47,18 @@ class TestCallYuntuAPI: item_id="test_item_123", publish_time=datetime(2025, 1, 1), industry_id="20", - session_id="test_session", + aadvid="1648829117232140", + auth_token="sessionid=test_session", ) - assert result["code"] == 0 + assert result["status"] == 0 assert result["data"]["total_show_cnt"] == 100000 async def test_call_with_correct_parameters(self): - """Test that API is called with correct parameters.""" + """Test that API is called with correct parameters (T-027 format).""" mock_response = MagicMock() mock_response.status_code = 200 - mock_response.json.return_value = {"code": 0, "data": {}} + mock_response.json.return_value = {"status": 0, "data": {}} mock_client = AsyncMock() mock_client.post.return_value = mock_response @@ -64,26 +70,27 @@ class TestCallYuntuAPI: item_id="video_001", publish_time=datetime(2025, 1, 15), industry_id="30", - session_id="session_abc", + aadvid="1648829117232140", + auth_token="sessionid=session_abc", ) mock_client.post.assert_called_once() call_args = mock_client.post.call_args - # 验证URL + # 验证URL包含aadvid assert "GetContentMaterialAnalysisInfo" in call_args.args[0] + assert "aadvid=1648829117232140" in call_args.args[0] - # 验证请求体 + # 验证请求体 - T-027: 日期格式 YYYYMMDD json_data = call_args.kwargs["json"] assert json_data["object_id"] == "video_001" - assert json_data["start_date"] == "2025-01-15" - assert json_data["end_date"] == "2025-02-14" # +30天 - assert json_data["industry_id_list"] == ["30"] + assert json_data["start_date"] == "20250115" # YYYYMMDD + assert json_data["end_date"] == "20250214" # +30天 + assert json_data["industry_id_list"] == ["30"] # 字符串数组 - # 验证headers包含sessionid + # 验证headers - T-027: 直接使用 auth_token headers = call_args.kwargs["headers"] - assert "Cookie" in headers - assert "sessionid=session_abc" in headers["Cookie"] + assert headers["Cookie"] == "sessionid=session_abc" async def test_call_session_invalid_401(self): """Test handling of 401 response (session invalid).""" @@ -101,7 +108,8 @@ class TestCallYuntuAPI: item_id="test", publish_time=datetime.now(), industry_id="20", - session_id="invalid_session", + aadvid="123", + auth_token="sessionid=invalid_session", ) assert exc_info.value.status_code == 401 @@ -122,7 +130,8 @@ class TestCallYuntuAPI: item_id="test", publish_time=datetime.now(), industry_id="20", - session_id="invalid_session", + aadvid="123", + auth_token="sessionid=invalid_session", ) async def test_call_api_error_500(self): @@ -142,18 +151,19 @@ class TestCallYuntuAPI: item_id="test", publish_time=datetime.now(), industry_id="20", - session_id="session", + aadvid="123", + auth_token="sessionid=session", ) assert exc_info.value.status_code == 500 async def test_call_business_error(self): - """Test handling of business error (code != 0).""" + """Test handling of business error (status != 0).""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "code": 1001, - "message": "Invalid parameter", + "status": 1001, + "msg": "Invalid parameter", } mock_client = AsyncMock() @@ -167,7 +177,8 @@ class TestCallYuntuAPI: item_id="test", publish_time=datetime.now(), industry_id="20", - session_id="session", + aadvid="123", + auth_token="sessionid=session", ) assert "Invalid parameter" in exc_info.value.message @@ -185,7 +196,8 @@ class TestCallYuntuAPI: item_id="test", publish_time=datetime.now(), industry_id="20", - session_id="session", + aadvid="123", + auth_token="sessionid=session", ) assert "timeout" in exc_info.value.message.lower() @@ -203,62 +215,24 @@ class TestCallYuntuAPI: item_id="test", publish_time=datetime.now(), industry_id="20", - session_id="session", + aadvid="123", + auth_token="sessionid=session", ) - async def test_call_without_session_id(self): - """Test API call without providing session_id (gets from pool).""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"code": 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): - with patch( - "app.services.yuntu_api.get_session_with_retry" - ) as mock_get_session: - mock_get_session.return_value = "pool_session" - - result = await call_yuntu_api( - item_id="test", - publish_time=datetime.now(), - industry_id="20", - ) - - assert result["code"] == 0 - mock_get_session.assert_called_once() - - async def test_call_no_session_available(self): - """Test API call when no session is available.""" - with patch( - "app.services.yuntu_api.get_session_with_retry" - ) as mock_get_session: - mock_get_session.return_value = None - - with pytest.raises(YuntuAPIError) as exc_info: - await call_yuntu_api( - item_id="test", - publish_time=datetime.now(), - industry_id="20", - ) - - assert "session" in exc_info.value.message.lower() - class TestGetVideoAnalysis: - """Tests for get_video_analysis function with retry logic (T-022).""" + """Tests for get_video_analysis function with retry logic (T-022, T-027).""" async def test_success_first_try(self): """Test successful call on first attempt.""" - with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: - mock_session.return_value = "valid_session" + with patch("app.services.yuntu_api.get_random_config") as mock_config: + mock_config.return_value = { + "aadvid": "123", + "auth_token": "sessionid=valid_session", + } with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: - mock_call.return_value = {"code": 0, "data": {"a3_increase_cnt": 100}} + mock_call.return_value = {"status": 0, "data": {"a3_increase_cnt": "100"}} result = await get_video_analysis( item_id="test", @@ -266,20 +240,24 @@ class TestGetVideoAnalysis: industry_id="20", ) - assert result["data"]["a3_increase_cnt"] == 100 + assert result["data"]["a3_increase_cnt"] == "100" assert mock_call.call_count == 1 async def test_retry_on_session_invalid(self): """Test retry when session is invalid.""" - with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: - mock_session.side_effect = ["session_1", "session_2", "session_3"] + with patch("app.services.yuntu_api.get_random_config") as mock_config: + mock_config.side_effect = [ + {"aadvid": "123", "auth_token": "sessionid=session_1"}, + {"aadvid": "456", "auth_token": "sessionid=session_2"}, + {"aadvid": "789", "auth_token": "sessionid=session_3"}, + ] with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: # 前两次失败,第三次成功 mock_call.side_effect = [ SessionInvalidError("Invalid"), SessionInvalidError("Invalid"), - {"code": 0, "data": {}}, + {"status": 0, "data": {}}, ] with patch("app.services.yuntu_api.session_pool") as mock_pool: @@ -290,15 +268,15 @@ class TestGetVideoAnalysis: max_retries=3, ) - assert result["code"] == 0 + assert result["status"] == 0 assert mock_call.call_count == 3 # 验证失效的session被移除 - assert mock_pool.remove.call_count == 2 + assert mock_pool.remove_by_auth_token.call_count == 2 async def test_max_retries_exceeded(self): """Test that error is raised after max retries.""" - with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: - mock_session.return_value = "session" + with patch("app.services.yuntu_api.get_random_config") as mock_config: + mock_config.return_value = {"aadvid": "123", "auth_token": "sessionid=session"} with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: mock_call.side_effect = SessionInvalidError("Invalid") @@ -316,8 +294,8 @@ class TestGetVideoAnalysis: async def test_no_retry_on_api_error(self): """Test that non-session errors don't trigger retry.""" - with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: - mock_session.return_value = "session" + with patch("app.services.yuntu_api.get_random_config") as mock_config: + mock_config.return_value = {"aadvid": "123", "auth_token": "sessionid=session"} with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: mock_call.side_effect = YuntuAPIError("Server error", status_code=500) @@ -332,10 +310,10 @@ class TestGetVideoAnalysis: assert mock_call.call_count == 1 assert exc_info.value.status_code == 500 - async def test_no_session_available(self): - """Test error when no session is available.""" - with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: - mock_session.return_value = None + async def test_no_config_available(self): + """Test error when no config is available.""" + with patch("app.services.yuntu_api.get_random_config") as mock_config: + mock_config.return_value = None with pytest.raises(YuntuAPIError): await get_video_analysis( @@ -349,7 +327,7 @@ class TestParseAnalysisResponse: """Tests for parse_analysis_response function.""" def test_parse_complete_response(self): - """Test parsing complete response data.""" + """Test parsing complete response data (T-027: handles string values).""" response = { "data": { "total_show_cnt": 100000, @@ -359,17 +337,17 @@ class TestParseAnalysisResponse: "natural_play_cnt": 40000, "ad_play_cnt": 10000, "effective_play_cnt": 30000, - "a3_increase_cnt": 500, - "ad_a3_increase_cnt": 100, - "natural_a3_increase_cnt": 400, + "a3_increase_cnt": "500", # 字符串 + "ad_a3_increase_cnt": "100", + "natural_a3_increase_cnt": "400", "after_view_search_uv": 1000, "after_view_search_pv": 1500, "brand_search_uv": 200, "product_search_uv": 300, "return_search_cnt": 50, - "cost": 10000.5, + "cost": 10000, "natural_cost": 0, - "ad_cost": 10000.5, + "ad_cost": 10000, } } @@ -377,9 +355,11 @@ class TestParseAnalysisResponse: assert result["total_show_cnt"] == 100000 assert result["natural_show_cnt"] == 80000 - assert result["a3_increase_cnt"] == 500 + assert result["a3_increase_cnt"] == 500 # 转为整数 + assert result["ad_a3_increase_cnt"] == 100 + assert result["natural_a3_increase_cnt"] == 400 assert result["after_view_search_uv"] == 1000 - assert result["cost"] == 10000.5 + assert result["cost"] == 10000 def test_parse_empty_response(self): """Test parsing empty response.""" @@ -404,7 +384,7 @@ class TestParseAnalysisResponse: response = { "data": { "total_show_cnt": 50000, - "a3_increase_cnt": 100, + "a3_increase_cnt": "100", } } @@ -414,3 +394,21 @@ class TestParseAnalysisResponse: assert result["a3_increase_cnt"] == 100 assert result["natural_show_cnt"] == 0 # Default value assert result["cost"] == 0 # Default value + + def test_parse_string_numbers(self): + """Test parsing string numbers to int (T-027).""" + response = { + "data": { + "a3_increase_cnt": "1689071", + "ad_a3_increase_cnt": "36902", + "natural_a3_increase_cnt": "1652169", + "cost": 785000, + } + } + + 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 diff --git a/doc/PRD.md b/doc/PRD.md index f16b7bf..cd48a80 100644 --- a/doc/PRD.md +++ b/doc/PRD.md @@ -64,6 +64,15 @@ KOL Insight 旨在解决这一痛点,提供批量数据查询和智能成本 |----|----------|----------| | US-007 | 作为运营人员,我想要点击视频链接直接跳转,以便快速查看原视频 | 1. 视频链接可点击
2. 新窗口打开视频页面 | + + +#### P0 - 视频分析增强 + +| ID | 用户故事 | 验收标准 | +|----|----------|----------| +| US-008 | 作为运营人员,我想要查看视频的详细分析数据(触达、A3、搜索、费用、成本指标),以便全面评估视频投放效果 | 1. 调用巨量云图API获取实时数据
2. 展示6大类25+指标
3. 成本指标自动计算
4. A3指标更新到数据库 | + + ### 2.3 用户旅程 **核心用户旅程:批量查询 KOL 数据** @@ -86,6 +95,7 @@ KOL Insight 旨在解决这一痛点,提供批量数据查询和智能成本 ### 3.1 功能架构 + ``` KOL Insight ├── 数据查询模块 @@ -99,8 +109,13 @@ KOL Insight ├── 数据展示模块 │ ├── 结果列表展示 │ └── 视频链接跳转 -└── 数据导出模块 - └── Excel/CSV导出 +├── 数据导出模块 +│ └── Excel/CSV导出 +└── 视频分析模块 (NEW) + ├── SessionID池管理 + ├── 巨量云图API集成 + ├── 实时数据获取与更新 + └── 视频分析报表展示 ``` ### 3.2 功能详情 @@ -135,6 +150,19 @@ KOL Insight |--------|------|--------------|--------|----------| | 数据导出 | 将查询结果导出为 Excel/CSV 格式 | US-005 | P1 | 文件可下载,数据完整,中文列名 | + + +#### 3.2.5 视频分析模块 + +| 功能点 | 描述 | 关联用户故事 | 优先级 | 验收标准 | +|--------|------|--------------|--------|----------| +| SessionID池管理 | 从内部API获取Cookie列表,随机选取sessionid用于请求 | US-008 | P0 | 1. 调用内部API获取100个sessionid
2. 随机选取机制实现
3. 失败自动切换重试(最多3次) | +| 巨量云图API封装 | 调用GetContentMaterialAnalysisInfo获取视频分析数据 | US-008 | P0 | 1. 正确构造请求参数
2. 超时设置10秒
3. 错误处理和日志记录 | +| 视频分析接口 | GET /api/v1/videos/{item_id}/analysis | US-008 | P0 | 1. 返回6大类指标
2. 计算指标准确
3. 除零返回null | +| 数据库A3指标更新 | 从API获取数据后更新数据库对应字段 | US-008 | P1 | 1. 更新total_new_a3_cnt
2. 更新heated_new_a3_cnt
3. 更新natural_new_a3_cnt
4. 更新total_cost | +| 视频分析报表 | 前端展示6大类25+指标 | US-008 | P1 | 1. 基础信息展示
2. 触达/A3/搜索/费用/成本指标展示
3. 数值格式化 | + + ## 4. 非功能需求 ### 4.1 性能需求 @@ -231,6 +259,8 @@ KOL Insight |------|------|--------| | PostgreSQL | 数据存储与查询 | 自建数据库 | | 品牌API | 根据品牌ID获取品牌名称 | 内部API (api.internal.intelligrow.cn) | +| Cookie池API | 获取巨量云图SessionID列表 | 内部API (api.internal.intelligrow.cn) | +| 巨量云图API | 获取视频分析数据 | 巨量云图 (yuntu.oceanengine.com) | **品牌API详情**: @@ -240,6 +270,74 @@ KOL Insight - 文档:https://api.internal.intelligrow.cn/docs#/云图 + + +**品牌API认证与响应格式**: +- 认证方式:Bearer Token(`Authorization: Bearer {token}`) +- Token配置:通过环境变量 `BRAND_API_TOKEN` 配置 +- 响应格式: +```json +{ + "total": 1, + "last_updated": "2025-12-30T11:28:40.738185", + "has_more": 0, + "data": [ + {"industry_id": 20, "industry_name": "母婴", "brand_id": 533661, "brand_name": "Giving/启初"} + ] +} +``` +- 解析方式:从 `data[0].brand_name` 获取品牌名称 + + + + + +**Cookie池API详情**: +- 接口地址:`/v1/yuntu/get_cookie` +- 请求方式:GET +- 认证方式:Bearer Token(`Authorization: Bearer {YUNTU_API_TOKEN}`) +- 用途:获取巨量云图认证信息列表(aadvid + auth_token) +- **使用方式**:随机选取任意一组 aadvid/auth_token,避免限流 +- 示例: +```bash +curl -X 'GET' \ + 'https://api.internal.intelligrow.cn/v1/yuntu/get_cookie?page=1&page_size=100' \ + -H 'Authorization: Bearer {YUNTU_API_TOKEN}' +``` +- 响应关键字段: + - `data[].aadvid` - 云图API的URL参数 + - `data[].auth_token` - Cookie头完整值(格式:`sessionid=xxx`) + +**巨量云图API详情**: +- 接口地址:`POST /yuntu_common/api/content/trigger_analysis/GetContentMaterialAnalysisInfo?aadvid={AADVID}` +- 基础URL:`https://yuntu.oceanengine.com` +- 认证方式:Cookie头直接使用 `auth_token` 完整值 +- 用途:获取视频触达、A3、搜索、费用等分析数据 +- 请求参数: +```json +{ + "is_my_video": "0", + "object_id": "{item_id}", + "object_type": 2, + "start_date": "{YYYYMMDD格式}", + "end_date": "{start_date+30天,YYYYMMDD格式}", + "assist_type": 3, + "assist_video_type": 3, + "industry_id_list": ["{数据库中视频的industry_id,字符串格式}"], + "trigger_point_id_list": ["610000", "610300", "610301"] +} +``` +- **⚠️ 参数格式要求**: + - 日期格式必须为 `YYYYMMDD`(如 `20251014`),不是 `YYYY-MM-DD` + - `industry_id_list` 使用数据库中视频的 industry_id,传字符串数组 + - Cookie 头直接使用 `auth_token` 值(已包含 `sessionid=xxx`) +- 关键响应字段: + - `data.a3_increase_cnt` - 新增A3(字符串类型) + - `data.ad_a3_increase_cnt` - 加热新增A3(字符串类型) + - `data.natural_a3_increase_cnt` - 自然新增A3(字符串类型) + - `data.cost` - 总花费(单位可能是分) + + ### 6.2 内部接口 @@ -247,6 +345,7 @@ KOL Insight |------|------|------|------| | /api/v1/query | POST | 批量查询KOL视频数据 | FastAPI 后端服务提供 | | /api/v1/export | GET | 导出查询结果为Excel/CSV | FastAPI 后端服务提供 | +| /api/v1/videos/{item_id}/analysis | GET | 获取单个视频分析数据 | FastAPI 后端服务提供 (NEW) | **API 架构说明**: diff --git a/doc/UIDesign.md b/doc/UIDesign.md index acb7b66..4059dfe 100644 --- a/doc/UIDesign.md +++ b/doc/UIDesign.md @@ -7,25 +7,25 @@ | 版本 | v1.0 | | 创建日期 | 2026-01-28 | | 来源文档 | DevelopmentPlan.md, PRD.md, FeatureSummary.md | -| 品牌主体 | 麦秒思AI制作 | +| 品牌主体 | 秒思AI制作 | ## 1. 设计概述 ### 1.1 设计原则 -**麦秒思AI设计语言** +**秒思AI设计语言** | 原则 | 说明 | 应用 | |------|------|------| | 优雅简洁 | 去除冗余元素,聚焦核心功能 | 单页应用,扁平化设计 | | 专业可信 | 体现数据分析的专业性 | 稳重色系,清晰的信息层级 | | 高效直观 | 减少用户学习成本 | 明确的操作流程,即时反馈 | -| 品牌一致 | 强化麦秒思AI品牌形象 | 统一使用品牌标识和色彩 | +| 品牌一致 | 强化秒思AI品牌形象 | 统一使用品牌标识和色彩 | **品牌元素** -- **Logo**: doc/ui/muse.svg (麦秒思AI品牌标识) -- **Slogan**: "麦秒思AI制作" (展示在关键位置) +- **Logo**: doc/ui/muse.svg (秒思AI品牌标识) +- **Slogan**: "秒思AI制作" (展示在关键位置) - **色调**: 专业、现代、科技感 ### 1.2 页面总览 @@ -76,7 +76,7 @@ ┌────────────────────────────────────────────────────────────────────────────┐ │ ┌──────────────────────────────────────────────────────────────────────┐ │ │ │ Header (品牌头部) │ │ -│ │ ┌──────┐ [麦秒思AI制作] │ │ +│ │ ┌──────┐ [秒思AI制作] │ │ │ │ │ MUSE │ KOL Insight - 云图数据查询分析 │ │ │ │ │ Logo │ (品牌标识 + 产品名称) │ │ │ │ └──────┘ │ │ @@ -123,7 +123,7 @@ ├────────────────────────────────────────────────────────────────────────────┤ │ ┌──────────────────────────────────────────────────────────────────────┐ │ │ │ Footer │ │ -│ │ © 2026 麦秒思AI制作 | KOL Insight v1.0 │ │ +│ │ © 2026 秒思AI制作 | KOL Insight v1.0 │ │ │ └──────────────────────────────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────────────────┘ ``` @@ -132,7 +132,7 @@ | 组件ID | 组件名称 | 类型 | 说明 | 交互 | |--------|----------|------|------|------| -| C-001 | 品牌头部 | Header | 展示麦秒思AI品牌Logo和产品名称 | 静态展示 | +| C-001 | 品牌头部 | Header | 展示秒思AI品牌Logo和产品名称 | 静态展示 | | C-002 | 查询方式选择器 | Radio Group | 三种查询方式单选 | 点击切换查询方式 | | C-003 | 查询输入框 | Textarea | 批量输入或昵称输入 | 文本输入 | | C-004 | 查询按钮组 | Button Group | 清空、开始查询 | 点击执行操作 | @@ -523,7 +523,7 @@ ### 5.1 色彩规范 -**麦秒思AI品牌色系** +**秒思AI品牌色系** | 用途 | 色值 | 示例 | 说明 | |------|------|------|------| @@ -665,21 +665,21 @@ Mobile (< 768px): | 位置 | 尺寸 | 说明 | |------|------|------| -| Header 左侧 | 高度 40px | 麦秒思AI Logo (doc/ui/muse.svg) | +| Header 左侧 | 高度 40px | 秒思AI Logo (doc/ui/muse.svg) | | Favicon | 32x32px | 简化版 Logo 图标 | | 加载动画 | - | 可选:Logo 动效 | **品牌声明位置** -- Header 右上角:"麦秒思AI制作" -- Footer 中央:"© 2026 麦秒思AI制作 | KOL Insight v1.0" +- Header 右上角:"秒思AI制作" +- Footer 中央:"© 2026 秒思AI制作 | KOL Insight v1.0" **Header 品牌区域详细设计** ``` ┌────────────────────────────────────────────────────────────────┐ │ ┌──────┐ │ -│ │ │ KOL Insight 麦秒思AI制作 │ +│ │ │ KOL Insight 秒思AI制作 │ │ │ MUSE │ 云图数据查询分析 │ │ │ Logo │ (产品名称 + Slogan) (品牌声明) │ │ │ │ │ @@ -838,7 +838,7 @@ Mobile (< 768px): **关键设计决策** - **单页应用**: 简化交互流程,提升用户体验 -- **品牌强化**: 多处展示"麦秒思AI制作",建立品牌认知 +- **品牌强化**: 多处展示"秒思AI制作",建立品牌认知 - **数据优先**: 核心是数据展示,UI 简洁不干扰 - **响应式**: 支持桌面/平板/移动端访问 @@ -846,5 +846,5 @@ Mobile (< 768px): **文档版本**: v1.0 **最后更新**: 2026-01-28 -**设计团队**: 麦秒思AI +**设计团队**: 秒思AI **审核状态**: 待审核 (建议运行 `/ru` 进行评审) diff --git a/doc/review-UIDesign-claude.md b/doc/review-UIDesign-claude.md index 319ac2d..5dae59b 100644 --- a/doc/review-UIDesign-claude.md +++ b/doc/review-UIDesign-claude.md @@ -57,7 +57,7 @@ | 布局风格统一 | ✅ | 垂直布局,从上到下:Header → 查询区 → 结果区 → Footer | | 交互模式一致 | ✅ | 查询 → 展示 → 导出流程清晰 | | 状态覆盖完整 | ✅ | 默认态、输入态、查询中、结果态、空结果态、错误态 | -| 品牌元素应用 | ✅ | 麦秒思AI Logo、Slogan、品牌色系统一应用 | +| 品牌元素应用 | ✅ | 秒思AI Logo、Slogan、品牌色系统一应用 | | 设计规范完整 | ✅ | 色彩、字体、间距、圆角、阴影规范完整 | | 响应式设计 | ✅ | 考虑了 Mobile/Tablet/Desktop 三种断点 | @@ -143,7 +143,7 @@ | 交互说明清晰 | ✅ | 8种交互场景全部说明 | | 用户流程图 | ✅ | 核心流程、辅助流程、异常流程全部包含 | | 设计规范统一 | ✅ | 色彩、字体、间距、圆角、阴影规范完整 | -| 品牌元素应用 | ✅ | 麦秒思AI Logo、Slogan、品牌色完整应用 | +| 品牌元素应用 | ✅ | 秒思AI Logo、Slogan、品牌色完整应用 | | 数据展示规范 | ✅ | 26个字段完整列出,格式化规则明确 | | 响应式设计 | ✅ | Mobile/Tablet/Desktop 三种断点考虑 | @@ -155,7 +155,7 @@ - 符合开发计划的技术架构(Next.js App Router) 2. **品牌一致性强** ⭐⭐⭐ - - 麦秒思AI品牌元素贯穿整个设计 + - 秒思AI品牌元素贯穿整个设计 - Logo、Slogan、品牌色系统一应用 - Header 和 Footer 强化品牌认知 @@ -193,7 +193,7 @@ | 操作效率 | ⭐⭐⭐⭐⭐ | 批量查询、一键导出,效率高 | | 错误提示 | ⭐⭐⭐⭐ | 错误态有明确提示和重试引导 | | 视觉层次 | ⭐⭐⭐⭐⭐ | 查询区 → 结果区层次清晰 | -| 品牌认知 | ⭐⭐⭐⭐⭐ | 多处展示麦秒思AI品牌元素 | +| 品牌认知 | ⭐⭐⭐⭐⭐ | 多处展示秒思AI品牌元素 | | 响应式体验 | ⭐⭐⭐⭐ | 考虑了移动端适配 | ## 评审结论 @@ -211,7 +211,7 @@ UIDesign 文档整体质量优秀,设计完整、规范统一、品牌一致 **优点总结**: - ✅ 单页应用设计合理,操作流程简洁高效 -- ✅ 品牌元素应用完整,强化麦秒思AI品牌认知 +- ✅ 品牌元素应用完整,强化秒思AI品牌认知 - ✅ 设计规范详细,便于开发实现 - ✅ 状态覆盖全面,用户体验考虑周到 - ✅ 与开发计划高度契合 diff --git a/doc/review-tasks-claude.md b/doc/review-tasks-claude.md index 98c1e74..0b85085 100644 --- a/doc/review-tasks-claude.md +++ b/doc/review-tasks-claude.md @@ -4,751 +4,72 @@ | 项目 | 内容 | |------|------| -| 评审时间 | 2026-01-28 15:30 | -| 目标文档 | [doc/tasks.md](doc/tasks.md) | -| 参照文档 | [doc/UIDesign.md](doc/UIDesign.md), [doc/DevelopmentPlan.md](doc/DevelopmentPlan.md) | -| 问题统计 | **4 个严重 / 6 个一般 / 5 个建议** | -| 评审结论 | 🟡 **需修改后通过** | +| 评审时间 | 2026-01-28 17:35 | +| 目标文档 | doc/tasks.md | +| 参照文档 | doc/UIDesign.md, doc/DevelopmentPlan.md | +| 问题统计 | 0 个严重 / 4 个一般 / 2 个建议 | ## 覆盖度分析 ### DevelopmentPlan 覆盖 -#### Phase 1: 基础架构搭建 - -| 开发项 (DevelopmentPlan) | 对应任务 (tasks.md) | 状态 | 说明 | -|---------------------------|---------------------|------|------| -| T-001 前端项目初始化 + T-002 后端项目初始化 | **T-001 项目初始化** | ⚠️ | **合并为一个任务,粒度过大** | -| T-003 数据库配置 | T-002 数据库配置 | ✅ | 完全覆盖,含TDD要求 | -| T-004 基础 UI 框架 | T-003 基础 UI 框架 | ✅ | 完全覆盖,含品牌元素 | -| T-005 环境变量配置 | T-004 环境变量配置 | ✅ | 完全覆盖 | - -#### Phase 2: 核心功能开发 - -| 开发项 (DevelopmentPlan) | 对应任务 (tasks.md) | 状态 | 说明 | -|---------------------------|---------------------|------|------| -| T-006 查询 API 开发 (后端) | **T-005 查询 API 开发** | ✅ | 含TDD要求和100%覆盖率 | -| T-007 计算逻辑实现 (后端) | **T-006 计算逻辑实现** | ✅ | 含TDD要求和100%覆盖率 | -| T-008 品牌 API 批量集成 (后端) | **T-007 品牌 API 批量集成** | ✅ | 含TDD要求和100%覆盖率 | -| T-009 导出 API 开发 (后端) | **T-010 导出 API 开发** | ⚠️ | **依赖T-009前端组件,不合理** | -| T-010 查询表单组件 (前端) | T-008 查询表单组件 | ✅ | 标注"粗略实现" | -| T-011 结果表格组件 (前端) | T-009 结果表格组件 | ✅ | 标注"粗略实现" | -| T-012 导出按钮组件 (前端) | T-011 导出按钮组件 | ✅ | 标注"粗略实现" | -| **(未在 DevelopmentPlan 中)** | **T-012 主页面集成** | ⚠️ | **新增任务,导致编号错位** | - -#### Phase 3: 优化与测试 - -| 开发项 (DevelopmentPlan) | 对应任务 (tasks.md) | 状态 | 说明 | -|---------------------------|---------------------|------|------| -| T-013 错误处理 (前后端) | **T-013 错误处理** | ❌ | **编号错位** | -| T-014 性能优化 (后端) | **T-014 性能优化** | ❌ | **编号错位** | -| T-015 视频链接跳转 (前端) | **T-015 视频链接跳转** | ❌ | **编号错位** | -| T-016 部署配置 (前后端) | **T-016 部署配置** | ❌ | **编号错位** | -| T-017 集成测试 | **T-017 集成测试** | ❌ | **编号错位** | - -**总覆盖率**: 17/16 (tasks.md 新增1个任务) - -**关键问题**: -1. ❌ **任务编号不一致**: Phase 3 的5个任务编号都向后偏移一位 -2. ⚠️ **T-001 粒度过大**: 前后端初始化合并为一个任务 -3. ⚠️ **T-010 依赖错误**: 后端 API 不应依赖前端组件 T-009 -4. ⚠️ **T-012 新增任务**: DevelopmentPlan 中没有对应项 - ---- +| 开发项 | 对应任务 | 状态 | +|--------|----------|------| +| T-001 前端项目初始化 | T-001A | ✅ | +| T-002 后端项目初始化 | T-001B | ✅ | +| T-003 数据库配置 | T-002 | ✅ | +| T-004 基础 UI 框架 | T-003 | ✅ | +| T-005 环境变量配置 | T-004 | ✅ | +| T-006 查询 API 开发 | T-005 | ✅ | +| T-007 计算逻辑实现 | T-006 | ✅ | +| T-008 品牌 API 批量集成 | T-007 | ✅ | +| T-009 导出 API 开发 | T-010 | ✅ | +| T-010 查询表单组件 | T-008 | ✅ | +| T-011 结果表格组件 | T-009 | ✅ | +| T-012 导出按钮组件 | T-011 | ✅ | +| T-013 错误处理 | T-013 | ✅ | +| T-014 性能优化 | T-014 | ✅ | +| T-015 视频链接跳转 | T-015 | ✅ | +| T-016 部署配置 | T-016 | ✅ | +| T-017 集成测试 | T-017 | ✅ | ### UIDesign 覆盖 -| UI 页面/组件 | 对应任务 | 状态 | 说明 | -|-------------|----------|------|------| -| **P-001: 数据查询主页** | T-012 主页面集成 | ✅ | 单页应用集成 | -| **组件覆盖** | | | | -| C-001: 品牌头部 | T-003 基础 UI 框架 | ✅ | 包含 Logo 和品牌声明 | -| C-002: 查询方式选择器 | T-008 查询表单组件 | ✅ | Radio Group | -| C-003: 查询输入框 | T-008 查询表单组件 | ✅ | Textarea | -| C-004: 查询按钮组 | T-008 查询表单组件 | ✅ | 清空/开始查询 | -| C-005: 结果表格 | T-009 结果表格组件 | ✅ | 26字段表格 | -| C-006: 导出按钮组 | T-011 导出按钮组件 | ✅ | Excel/CSV 导出 | -| C-007: 分页器 | T-009 结果表格组件 | ✅ | 验收标准第9条 | -| C-008: 视频链接 | T-015 视频链接跳转 | ✅ | 新窗口打开 | -| C-009: Footer | T-003 基础 UI 框架 | ✅ | 版权信息 | -| **页面状态** | | | | -| 6种状态 | T-012 主页面集成 | ✅ | 验收标准第6-8条 | +| UI 页面 | 对应任务 | 状态 | +|---------|----------|------| +| P-001 数据查询主页 | T-011A (集成), T-008/009/011/015 | ✅ | -**总覆盖率**: 10/10 (100%) - -**UI覆盖评价**: ✅ 所有 UI 页面、组件、状态都有对应任务 - ---- +**总覆盖率**: 1/1 ## 任务质量分析 -| 检查项 | 通过数 | 总数 | 通过率 | -|--------|--------|------|--------| -| 有明确描述 | 17 | 17 | 100% | -| 有验收标准 | 17 | 17 | 100% | -| 验收标准清晰 | 17 | 17 | 100% | -| 依赖关系明确 | 16 | 17 | 94% | -| 粒度合适 | 16 | 17 | 94% | -| TDD 要求明确 | 7 | 12 | 58% | -| 测试覆盖率要求 | 7 | 12 | 58% | - -**质量问题**: -- ⚠️ **T-001 粒度过大**: 前后端初始化合并,无法并行开发 -- ⚠️ **后端任务 TDD 覆盖不全**: 仅 7/12 的后端任务有明确 TDD 要求 -- ❌ **缺少测试独立任务**: 100% 覆盖率嵌入开发任务,难以单独验收 - ---- +| 检查项 | 通过数 | 总数 | +|--------|--------|------| +| 有明确描述 | 27 | 27 | +| 有验收标准 | 27 | 27 | +| 粒度合适 | 25 | 27 | ## 问题清单 ### 严重问题 (Critical) - -#### C-1: T-001 任务粒度过大,前后端无法并行 -**位置**: [doc/tasks.md:43](doc/tasks.md:43) - -**问题描述**: -```markdown -| T-001 | 项目初始化 | 前后端分离架构:前端 Next.js,后端 FastAPI,配置 TypeScript、ESLint、Prettier | P0 | - | -``` - -T-001 包含: -1. 前端 Next.js 14.x 项目创建 -2. 后端 FastAPI 0.104+ 项目创建 -3. 前端 TypeScript、ESLint、Prettier 配置 -4. 后端 Python 依赖管理配置 -5. 验收标准6条(前端3条+后端3条) - -**影响**: -- 🚫 **无法并行开发**: 前端和后端开发者可能是不同人员,合并为一个任务导致无法同时开工 -- 🚫 **验收标准过多**: 6条验收标准涉及不同技术栈,验收时需要同时检查前后端 -- 🚫 **依赖关系不清晰**: T-002 数据库配置依赖 T-001,但实际只依赖后端部分 - -**建议修复**: -拆分为两个独立任务: -- **T-001A: 前端项目初始化** (依赖: 无) - - 创建 Next.js 14.x 项目 - - 配置 TypeScript、ESLint、Prettier - - 验收: 可运行 `pnpm dev` - -- **T-001B: 后端项目初始化** (依赖: 无) - - 创建 FastAPI 0.104+ 项目 - - 配置 Poetry/pip - - 验收: 可运行 `uvicorn main:app --reload` - -**优点**: -- ✅ 前后端可并行开发,节省时间 -- ✅ 验收标准更聚焦 -- ✅ 依赖关系更清晰(T-002 只依赖 T-001B) - ---- - -#### C-2: T-010 依赖关系错误 -**位置**: [doc/tasks.md:67](doc/tasks.md:67) - -**问题描述**: -```markdown -| T-010 | 导出 API 开发 | ... | P1 | T-006, T-007, T-009 | ... -``` - -T-010 (后端导出 API) 依赖 T-009 (前端结果表格组件),这是**逻辑错误**。 - -**分析**: -- T-010 是**后端 FastAPI** 接口,负责生成 Excel/CSV 文件 -- T-009 是**前端 React** 组件,负责展示表格 -- 后端 API 不应该依赖前端组件的实现 - -**实际依赖**: -- T-010 应该依赖 **T-006 (计算逻辑实现)** 和 **T-007 (品牌API集成)** -- 因为导出的数据需要包含计算后的指标和品牌名称 - -**验收标准第5条**: -``` -5. 使用中文列名作为表头 **(与 T-009 ResultTable 字段一致)** -``` -这说明是要求"字段一致性",而不是"依赖关系"。 - -**影响**: -- 🚫 **执行顺序混乱**: 开发者可能误以为要先完成前端表格才能开发后端导出API -- 🚫 **前后端耦合**: 后端依赖前端,违反分离架构原则 - -**建议修复**: -1. 修改依赖: `T-010 依赖: T-006, T-007` (移除 T-009) -2. 修改验收标准第5条: "使用中文列名作为表头 **(字段顺序和命名与前端 ResultTable 保持一致,参考共享的字段定义)**" -3. 建议: 创建共享的字段定义文件(如 `types/fields.ts`),前后端都引用 - ---- - -#### C-3: 缺少单元测试独立任务 -**位置**: 整个 tasks.md - -**问题描述**: -tasks.md 中有 **7个任务** 要求 TDD 和 100% 测试覆盖率: -- T-002: 数据库配置 (验收标准 7-8 条) -- T-005: 查询 API 开发 (验收标准 9-10 条) -- T-006: 计算逻辑实现 (验收标准 7-8 条) -- T-007: 品牌 API 批量集成 (验收标准 8-9 条) -- T-010: 导出 API 开发 (验收标准 10-11 条) -- T-013: 错误处理 (验收标准 8-9 条) -- T-017: 集成测试 (验收标准 9-11 条) - -但**没有单独的测试任务**,所有测试要求都嵌入在开发任务中。 - -**影响**: -- 🚫 **测试容易被忽略**: 开发进度紧张时,测试可能被压缩或跳过 -- 🚫 **无法单独追踪测试进度**: 测试覆盖率没有独立的验收里程碑 -- 🚫 **100% 覆盖率难以保证**: 嵌入在开发任务中,验收时可能只检查功能,不检查覆盖率 -- 🚫 **测试报告缺失**: T-017 要求生成覆盖率报告,但其他任务没有明确要求 - -**建议修复**: -在 Phase 3 增加测试里程碑任务: - -**方案A: 增加独立测试任务** -```markdown -| T-018 | 测试覆盖率验收 | 验证所有后端代码测试覆盖率 ≥ 100% | P1 | T-002, T-005~007, T-010, T-013 | -验收标准: -1. 数据库操作测试覆盖率 100% (T-002) -2. API集成测试覆盖率 100% (T-005) -3. 计算逻辑单元测试覆盖率 100% (T-006) -4. 品牌API单元测试覆盖率 100% (T-007) -5. 导出功能单元测试覆盖率 100% (T-010) -6. 错误处理分支覆盖率 100% (T-013) -7. 使用 pytest-cov 生成覆盖率报告 -8. 覆盖率报告上传到 CI/CD -``` - -**方案B: 在每个 Phase 结束增加测试验收点** -```markdown -## 3. Phase 2 任务 - 核心功能开发 - -### 3.3 测试验收 -| ID | 任务 | 描述 | 优先级 | 依赖 | 验收标准 | -|----|------|------|--------|------|----------| -| T-012A | Phase 2 测试验收 | 验证 Phase 2 所有后端任务测试覆盖率 | P0 | T-005~007, T-010 | 1. 所有后端代码覆盖率 ≥ 100%
2. 生成覆盖率报告 | -``` - ---- - -#### C-4: 任务编号与 DevelopmentPlan 不一致 -**位置**: Phase 3 所有任务 ([doc/tasks.md:88-101](doc/tasks.md)) - -**问题描述**: -tasks.md 新增了 T-012 (主页面集成),导致 Phase 3 的所有任务编号向后偏移一位: - -| DevelopmentPlan | tasks.md | 差异 | -|-----------------|----------|------| -| T-013 错误处理 | **T-013 错误处理** | ❌ 编号错位 | -| T-014 性能优化 | **T-014 性能优化** | ❌ 编号错位 | -| T-015 视频链接跳转 | **T-015 视频链接跳转** | ❌ 编号错位 | -| T-016 部署配置 | **T-016 部署配置** | ❌ 编号错位 | -| T-017 集成测试 | **T-017 集成测试** | ❌ 编号错位 | - -**影响**: -- 🚫 **文档引用混乱**: 在 DevelopmentPlan 中看到的 T-013 和 tasks.md 中的 T-013 不是同一个任务 -- 🚫 **沟通成本高**: 开发人员需要在两个文档之间切换时手动对照编号 -- 🚫 **代码注释/提交信息错误**: Git 提交信息中的任务 ID 可能指向错误的任务 - -**建议修复**: - -**方案A (推荐): 将 T-012 改为 T-008A** -```markdown -| T-008 | 查询表单组件 | ... | P0 | T-003 | -| T-008A | 主页面集成 | ... | P0 | T-008, T-009, T-011 | -| T-009 | 结果表格组件 | ... | P1 | T-003, T-006, T-007 | -``` -- 优点: Phase 3 编号与 DevelopmentPlan 完全一致 -- 缺点: 引入子编号 - -**方案B: 更新 DevelopmentPlan.md** -在 DevelopmentPlan.md 的 Phase 2 增加 T-012 任务 -- 优点: 保持 tasks.md 不变 -- 缺点: 需要修改 DevelopmentPlan.md - -**方案C: 在 tasks.md 增加对照表** -```markdown -## 附录: 与 DevelopmentPlan 任务编号对照 - -| tasks.md | DevelopmentPlan | 任务名称 | -|----------|-----------------|----------| -| T-013 | T-013 | 错误处理 | -| T-014 | T-014 | 性能优化 | -... -``` -- 优点: 不修改编号,只增加对照表 -- 缺点: 需要手动查表,增加认知负担 - ---- +无。 ### 一般问题 (Major) - -#### M-1: T-002 真实数据库测试要求缺少环境准备说明 -**位置**: [doc/tasks.md:46](doc/tasks.md:46) - -**问题描述**: -```markdown -6. **真实数据库测试**: 使用 .env 中的连接字符串连接真实数据库并验证 -``` - -验收标准要求连接"真实数据库",但没有说明: -- 真实数据库是否已经准备好? -- 数据库中是否有测试数据? -- 需要什么权限? - -**影响**: -- 开发者执行到 T-002 时可能发现数据库环境未就绪 -- 导致任务阻塞,无法继续 - -**建议修复**: -1. 在 T-002 依赖中增加: `依赖: T-001B (后端初始化), 数据库环境准备 (DBA)` -2. 在 T-004 环境变量配置中增加验收标准: "数据库连接字符串配置完成,数据库可访问" -3. 或在任务描述中明确标注: "需提前准备测试数据库环境,包含表结构和测试数据" - ---- - -#### M-2: T-012 主页面集成缺少状态管理方案说明 -**位置**: [doc/tasks.md:85](doc/tasks.md:85) - -**问题描述**: -```markdown -6. 页面状态管理: 默认态/输入态/查询中/结果态/空结果态/错误态 -``` - -验收标准提到"页面状态管理",但没有说明使用何种状态管理方案: -- React useState? -- Zustand? -- Redux Toolkit? -- Context API? - -**影响**: -- 前端开发者需要自行决定状态管理方案 -- 可能导致过度设计(引入 Redux)或过于简单(难以维护) - -**建议修复**: -在验收标准第6条补充说明: -```markdown -6. 页面状态管理: 默认态/输入态/查询中/结果态/空结果态/错误态 **(使用 React useState 管理,无需第三方库)** -``` - ---- - -#### M-3: T-007 品牌API并发限制和超时参数硬编码 -**位置**: [doc/tasks.md:64](doc/tasks.md:64) - -**问题描述**: -```markdown -3. 使用 asyncio.gather 批量并发请求(限制 10 并发) -6. 超时设置: 3秒 -``` - -验收标准硬编码了"10 并发"和"3 秒",未说明这些参数是否可配置。 - -**影响**: -- 生产环境可能需要调整并发数(如品牌API限流时降低并发) -- 超时时间可能需要根据网络环境调整 -- 硬编码参数难以适应不同环境 - -**建议修复**: -1. 将并发限制和超时时间配置到环境变量或配置文件 -2. 修改验收标准: -```markdown -3. 使用 asyncio.gather 批量并发请求,并发数可配置(默认 10) -6. 超时时间可配置(默认 3 秒) -7. 从环境变量读取配置: BRAND_API_CONCURRENCY, BRAND_API_TIMEOUT -``` - ---- - -#### M-4: T-009 与 T-010 字段一致性验证缺失 -**位置**: [doc/tasks.md:76](doc/tasks.md:76) - -**问题描述**: -T-009 (前端表格) 和 T-010 (后端导出) 都要求"使用中文列名",但没有明确如何保证字段一致性。 - -**当前状态**: -- T-009 验收标准: "展示 26 个字段,使用中文列名" -- T-010 验收标准: "使用中文列名作为表头 **(与 T-009 ResultTable 字段一致)**" - -**问题**: -- "字段一致"如何验证? -- 前端和后端是否共享字段定义? - -**影响**: -- 前端展示和导出文件的列名可能不一致 -- 导致用户混淆 - -**建议修复**: -1. 创建共享的字段定义文件: -```typescript -// shared/types/fields.ts -export const VIDEO_FIELDS = [ - { key: 'item_id', label: '视频ID', width: 120 }, - { key: 'title', label: '视频标题', width: 200 }, - // ... 24 more fields -] as const; -``` - -2. 修改 T-009 验收标准: -```markdown -2. 展示 26 个字段,使用共享字段定义文件 (shared/types/fields.ts) -``` - -3. 修改 T-010 验收标准: -```markdown -5. 使用共享字段定义文件作为表头,保证与前端表格字段顺序和命名完全一致 -``` - ---- - -#### M-5: T-014 性能优化缺少性能测试脚本 -**位置**: [doc/tasks.md:96](doc/tasks.md:96) - -**问题描述**: -T-014 定义了明确的性能指标: -- 查询响应时间 ≤ 3秒 (100条) -- 页面加载时间 ≤ 2秒 -- 导出响应时间 ≤ 5秒 (1000条) - -但验收标准只有"验证索引已创建",没有要求编写性能测试脚本。 - -**影响**: -- 性能指标难以自动化验证 -- 依赖人工测试,可能遗漏 -- 回归测试时无法快速验证性能 - -**建议修复**: -增加验收标准: -```markdown -6. **后端性能测试**: 编写性能测试脚本,验证响应时间指标 -7. **真实数据库测试**: 使用真实数据库和测试数据进行性能测试 -8. 性能测试报告: 生成性能测试报告,记录实际响应时间 -``` - ---- - -#### M-6: T-017 集成测试缺少性能测试用例 -**位置**: [doc/tasks.md:101](doc/tasks.md:101) - -**问题描述**: -T-017 集成测试有 8 个功能测试用例,但未包含 T-014 定义的性能指标验证。 - -**建议修复**: -在验收标准中增加性能测试用例: -```markdown -9. 测试用例: 性能指标验证 (查询≤3秒、导出≤5秒) -10. **真实数据库集成测试**: 使用 .env 中的真实数据库连接进行完整集成测试 -11. **后端测试覆盖率验证**: 确认所有后端代码测试覆盖率 ≥ 100% -12. **测试报告生成**: 使用 pytest-cov 生成覆盖率报告 -``` -(注: 验收标准 10-12 已存在,只需增加第9条) - ---- +1. 任务统计与优先级说明与实际任务清单不一致,且缺少迭代任务计数,导致计划与执行口径不统一,影响排期与资源分配。参考: doc/tasks.md:29-35, doc/tasks.md:189-201, doc/tasks.md:337-357 +2. 依赖图、执行检查清单、里程碑均未覆盖 T-019~T-026 迭代任务,迭代工作缺少清晰执行路径与交付节点,容易被遗漏或排期错误。参考: doc/tasks.md:113-187, doc/tasks.md:337-357 +3. 迭代任务(T-019~T-026)未在上游 DevelopmentPlan/UIDesign 中体现,且 T-026 为新页面无 UI 设计依据,存在范围漂移与验收依据不一致风险。参考: doc/tasks.md:337-357, doc/DevelopmentPlan.md:246-318, doc/UIDesign.md:31-128 +4. 多处任务要求真实数据库/性能/覆盖率验证,但未定义数据准备与测试环境前置条件,可能导致 T-002/T-014/T-017/T-018 无法直接执行。参考: doc/tasks.md:49, doc/tasks.md:103, doc/tasks.md:108-110 ### 改进建议 (Minor) - -#### S-1: 前端"粗略实现"说明不够具体 -**位置**: [doc/tasks.md:74, 76, 78, 85](doc/tasks.md) - -**问题描述**: -T-008/T-009/T-011/T-012 都标注了"粗略实现说明",但"粗略"的标准不明确。 - -**建议**: -在任务总览或关键技术点章节定义"粗略实现"标准: -```markdown -## 前端"粗略实现"标准 - -本项目前端采用"功能优先、样式从简"的开发策略: -- ✅ **功能完整**: 所有功能可用,交互流程完整 -- ✅ **样式简洁**: 使用 Tailwind 默认样式,无需过度美化 -- ✅ **品牌元素保留**: Logo、品牌色、品牌声明必须体现 -- ❌ **暂不支持**: 响应式适配、动画效果、深度优化 -``` - ---- - -#### S-2: 建议增加任务估时 -**位置**: 整个 tasks.md - -**问题描述**: -所有任务都没有工作量估时,无法评估项目整体时间和关键路径。 - -**建议**: -在任务总览表格增加"估时"列: -```markdown -| ID | 任务 | 描述 | 优先级 | 依赖 | 估时 | 验收标准 | -|----|------|------|--------|------|------|----------| -| T-001 | 项目初始化 | ... | P0 | - | 1天 | ... | -``` - -**参考估时** (仅供参考): -- T-001: 1天 (前后端分离后: 0.5天 × 2) -- T-002: 1天 -- T-005: 2天 (含 TDD) -- T-009: 2天 -- T-012: 2天 - ---- - -#### S-3: T-016 部署配置缺少监控和日志方案 -**位置**: [doc/tasks.md:99](doc/tasks.md:99) - -**问题描述**: -T-016 部署配置只涉及 Docker 和环境变量,未涉及生产环境监控和日志收集。 - -**建议**: -增加验收标准: -```markdown -8. 日志配置: 前端 console 输出,后端使用 Python logging 模块输出到文件 -9. (可选) 监控配置: 接入 Sentry 或 Prometheus 进行错误监控 -``` - ---- - -#### S-4: 任务依赖图与实际任务ID不一致 -**位置**: [doc/tasks.md:105](doc/tasks.md:105) - -**问题描述**: -第5节"任务依赖图"仍使用 DevelopmentPlan 的任务编号,与 tasks.md 实际任务ID不一致。 - -**建议修复**: -更新任务依赖图,使用 tasks.md 的任务ID (T-001~T-017): -``` -Phase 1: 基础架构 -T-001 (项目初始化) - ├── T-002 (数据库配置) - ├── T-003 (基础UI框架) - └── T-004 (环境变量配置) - -Phase 2: 核心功能 -T-002 ──▶ T-005 (查询API) ──▶ T-006 (计算逻辑) ──▶ T-009 (结果表格) - │ │ │ - └──▶ T-007 (品牌API) │ │ - │ │ -T-003 ──▶ T-008 (查询表单) │ │ - │ │ - T-010 (导出API) ◀───────────────┤ - │ │ - T-011 (导出按钮) ◀──────────────┤ - │ -T-008, T-009, T-011 ──▶ T-012 (主页面集成) ────────────┘ - -Phase 3: 优化测试 -T-012 ──▶ T-013 (错误处理) ──▶ T-014 (性能优化) - │ │ - ├──▶ T-015 (视频链接) │ - │ │ - └──▶ T-016 (部署配置) │ - │ - T-017 (集成测试) -``` - ---- - -#### S-5: 建议增加功能ID(F-xxx)对应关系 -**位置**: 整个 tasks.md - -**建议**: -在"关联功能"列增加功能ID引用,便于追溯需求: -```markdown -| ID | 任务 | 描述 | 优先级 | 依赖 | 关联功能 | 验收标准 | -|----|------|------|--------|------|----------|----------| -| T-005 | 查询 API 开发 | ... | P0 | T-002 | F-001, F-002, F-003 | ... | -| T-006 | 计算逻辑实现 | ... | P0 | T-005 | F-004, F-005, F-006 | ... | -``` - ---- - -## 依赖关系分析 - -### 关键路径 - -``` -T-001 (项目初始化) - │ - ├─→ T-002 (数据库配置) - │ │ - │ └─→ T-005 (查询API) - │ │ - │ ├─→ T-006 (计算逻辑) - │ │ │ - │ │ └─→ T-010 (导出API) - │ │ - │ └─→ T-007 (品牌API) - │ │ - │ └─→ T-009 (结果表格) - │ │ - │ └─→ T-012 (主页面集成) - │ │ - │ └─→ T-013 (错误处理) - │ │ - │ └─→ T-017 (集成测试) - │ - └─→ T-003 (基础UI) - │ - └─→ T-008 (查询表单) - │ - └─→ T-012 (主页面集成) -``` - -**关键路径**: -T-001 → T-002 → T-005 → T-007 → T-009 → T-012 → T-013 → T-017 - -**可并行任务**: -- T-002 (数据库) 和 T-003 (基础UI) 可并行 -- T-006 (计算逻辑) 和 T-007 (品牌API) 可并行 -- T-013/T-014/T-015 可并行 - ---- +1. 主页面标题与 UIDesign 头部文案不一致(缺少“云图数据查询分析”),建议补齐以满足品牌一致性。参考: doc/tasks.md:91, doc/UIDesign.md:80-82 +2. 覆盖率验收任务 T-018 同时包含指标定义、报告产出、CI 集成,建议拆分为“覆盖率验收”与“CI 集成”以降低任务粒度。参考: doc/tasks.md:110 ## 评审结论 -### 评审结果 +需修改后通过。 -🟡 **需修改后通过** - ---- - -### 主要优点 - -✅ **覆盖度完整**: -- 所有 DevelopmentPlan (16个任务) 和 UIDesign (10个组件) 都有对应任务 -- 新增 T-012 主页面集成任务是合理补充 - -✅ **验收标准详细**: -- 每个任务平均 6.2 条验收标准 -- 验收标准具体可操作,便于验收 -- T-006/T-014/T-017 的验收标准特别优秀 - -✅ **TDD 要求明确**: -- 7个关键后端任务都要求先写测试再写代码 -- 明确要求 100% 测试覆盖率和真实数据库测试 - -✅ **架构更新到位**: -- 任务描述已完全更新为前后端分离架构 (FastAPI + Next.js) -- 品牌元素(麦秒思AI)在任务中明确体现 - ---- - -### 关键问题 - -❌ **严重问题** (必须修复): -1. **C-1: T-001 粒度过大** - 前后端初始化应拆分,支持并行开发 -2. **C-2: T-010 依赖错误** - 后端 API 不应依赖前端组件 T-009 -3. **C-3: 缺少测试独立任务** - 100% 覆盖率需要独立验收里程碑 -4. **C-4: 任务编号不一致** - Phase 3 任务编号与 DevelopmentPlan 错位 - -⚠️ **一般问题** (建议修复): -1. **M-1: T-002 数据库环境准备** - 需明确数据库环境前置条件 -2. **M-2: T-012 状态管理方案** - 建议使用 React useState -3. **M-3: T-007 参数硬编码** - 并发和超时应可配置 -4. **M-4: T-009/T-010 字段一致性** - 建议共享字段定义文件 -5. **M-5: T-014 性能测试脚本** - 需编写自动化性能测试 -6. **M-6: T-017 性能测试用例** - 集成测试应包含性能验证 - ---- - -### 影响评估 - -**阻塞性问题**: -- 🚫 **C-1 (T-001 粒度过大)**: 导致前后端无法并行开发,延长项目周期 -- 🚫 **C-2 (T-010 依赖错误)**: 导致执行顺序混乱,前后端耦合 - -**质量风险**: -- ⚠️ **C-3 (缺少测试任务)**: 100% 覆盖率难以保证,可能降低代码质量 -- ⚠️ **M-5/M-6 (性能测试缺失)**: 性能指标无法自动化验证 - -**进度风险**: -- ⚠️ **M-1 (数据库环境未就绪)**: 可能导致 T-002 阻塞 -- ⚠️ **无任务估时**: 难以评估项目整体进度和关键路径 - ---- - -## 下一步行动 - -### 必须修改 (Critical) - 预估 1.5 小时 - -- [ ] **C-1: 拆分 T-001** 为 T-001A (前端初始化) 和 T-001B (后端初始化) - - 预估时间: 30分钟 - - 影响范围: tasks.md, DevelopmentPlan.md - -- [ ] **C-2: 修正 T-010 依赖** 移除 T-009,改为 `T-006, T-007` - - 预估时间: 10分钟 - - 影响范围: tasks.md:67 - -- [ ] **C-3: 增加测试任务** 在 Phase 3 增加 T-018 测试覆盖率验收 - - 预估时间: 20分钟 - - 影响范围: tasks.md Phase 3 - -- [ ] **C-4: 统一任务编号** 选择方案A/B/C 修复编号不一致问题 - - 预估时间: 30分钟 - - 影响范围: tasks.md 或 DevelopmentPlan.md - ---- - -### 建议修改 (Major) - 预估 1 小时 - -- [ ] **M-1: T-002 数据库环境说明** 明确数据库准备前置条件 - - 预估时间: 10分钟 - -- [ ] **M-2: T-012 状态管理说明** 补充 React useState 方案 - - 预估时间: 5分钟 - -- [ ] **M-3: T-007 参数配置化** 并发和超时改为可配置 - - 预估时间: 15分钟 - -- [ ] **M-4: T-009/T-010 字段一致性** 增加共享字段定义要求 - - 预估时间: 15分钟 - -- [ ] **M-5: T-014 性能测试脚本** 增加性能测试验收标准 - - 预估时间: 10分钟 - -- [ ] **M-6: T-017 性能测试用例** 增加性能测试用例 - - 预估时间: 5分钟 - ---- - -### 可选优化 (Minor) - 预估 1 小时 - -- [ ] **S-1: 定义"粗略实现"标准** 增加前端开发标准说明 -- [ ] **S-2: 增加任务估时** 为每个任务增加工作量估时(人天) -- [ ] **S-3: T-016 监控配置** 增加日志和监控验收标准 -- [ ] **S-4: 更新依赖图** 使用 tasks.md 的实际任务ID -- [ ] **S-5: 增加功能ID** 在关联功能列增加 F-xxx 引用 - ---- - -### 修复优先级汇总 - -| 优先级 | 问题ID | 问题描述 | 预估时间 | 阻塞风险 | -|--------|--------|----------|----------|----------| -| P0 | C-1 | T-001 拆分 | 30分钟 | ⚠️ 高 | -| P0 | C-2 | T-010 依赖修正 | 10分钟 | ⚠️ 高 | -| P0 | C-3 | 增加测试任务 | 20分钟 | ⚠️ 中 | -| P0 | C-4 | 统一任务编号 | 30分钟 | ⚠️ 中 | -| P1 | M-1~M-6 | 6个一般问题 | 60分钟 | ⚠️ 低 | -| P2 | S-1~S-5 | 5个改进建议 | 60分钟 | ✅ 无 | - -**预计修复总时间**: 约 3.5 小时 (P0-P2 全部) - ---- - -## 参考信息 - -### 文档链接 - -- 目标文档: [doc/tasks.md](doc/tasks.md) -- 上游文档1: [doc/UIDesign.md](doc/UIDesign.md) - UI 设计文档 -- 上游文档2: [doc/DevelopmentPlan.md](doc/DevelopmentPlan.md) - 开发计划 - -### 修改建议操作 - -建议使用 `/mt` 命令根据本评审报告的问题清单进行增量修改: -```bash -/mt # 增量修改 tasks.md -``` - ---- - -**评审人**: Claude Sonnet 4.5 -**评审日期**: 2026-01-28 15:30 -**评审版本**: tasks.md v1.0 -**评审耗时**: 45 分钟 -**评审方法**: 基于 `/rt` 评审技能,对比 UIDesign.md 和 DevelopmentPlan.md +### 下一步行动 +- [ ] 对齐任务总数与优先级统计,补充迭代任务到依赖图/清单/里程碑 +- [ ] 将 T-019~T-026 同步到 DevelopmentPlan/UIDesign(或明确为独立迭代范围) +- [ ] 增加真实数据库与测试数据准备任务(含环境获取方式) +- [ ] 修正页面头部文案与 UIDesign 一致 diff --git a/doc/tasks.md b/doc/tasks.md index c6a3634..2913dfe 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -26,12 +26,12 @@ ## 1. 任务总览 - + | 统计项 | 数量 | |--------|------| -| 总任务数 | 18 | -| P0 任务 | 10 | -| P1 任务 | 7 | +| 总任务数 | 27 | +| P0 任务 | 17 | +| P1 任务 | 9 | | P2 任务 | 1 | ## 2. Phase 1 任务 - 基础架构搭建 @@ -48,7 +48,7 @@ | T-002 | 数据库配置 | 配置 SQLAlchemy,定义数据模型,连接 PostgreSQL | P0 | T-001B | 1. SQLAlchemy 2.0+ 和 asyncpg 安装完成
2. 定义 KolVideo 模型(使用 SQLAlchemy ORM)
3. 数据库异步连接成功
4. 索引创建: star_id, star_unique_id, star_nickname
5. Alembic 迁移工具配置完成
6. **真实数据库测试**: 使用 .env 中的连接字符串连接真实数据库并验证
7. **TDD要求**: 编写数据库连接测试,模型测试,CRUD测试
8. **测试覆盖率**: 数据库操作测试覆盖率 ≥ 100% | -| T-003 | 基础 UI 框架 | 安装 Tailwind CSS,创建基础布局组件 | P0 | T-001A | 1. Tailwind CSS 配置完成
2. 品牌色系配置 (#4F46E5等)
3. 基础布局组件创建 (Header/Footer)
4. 麦秒思AI Logo 集成 (doc/ui/muse.svg) | +| T-003 | 基础 UI 框架 | 安装 Tailwind CSS,创建基础布局组件 | P0 | T-001A | 1. Tailwind CSS 配置完成
2. 品牌色系配置 (#4F46E5等)
3. 基础布局组件创建 (Header/Footer)
4. 秒思AI Logo 集成 (doc/ui/muse.svg) | | T-004 | 环境变量配置 | 配置开发/生产环境变量,数据库连接字符串 | P0 | T-001A, T-001B | 1. 前后端 .env.example 创建
2. 后端 DATABASE_URL 配置
3. 后端品牌 API 地址配置
4. 前端 NEXT_PUBLIC_API_URL 配置
5. .env 文件创建并添加到 .gitignore | @@ -88,7 +88,7 @@ |----|------|------|--------|------|----------| -| T-011A | 主页面集成 | 集成查询表单、结果表格和导出按钮,完成单页应用 **(前端粗略实现)** | P0 | T-008, T-009, T-011 | 1. page.tsx 创建单页应用
2. 品牌头部: Logo + "KOL Insight" + "麦秒思AI制作"
3. 查询区域集成 QueryForm
4. 结果区域集成 ResultTable 和 ExportButton
5. Footer: "© 2026 麦秒思AI制作"
6. 页面状态管理: 默认态/输入态/查询中/结果态/空结果态/错误态
7. 空状态组件: 引导文案 + 空盒子图标
8. 错误状态组件: 错误提示 + 重试按钮
9. **粗略实现说明**: 重点在功能集成,UI可简化,品牌元素必须保留 | +| T-011A | 主页面集成 | 集成查询表单、结果表格和导出按钮,完成单页应用 **(前端粗略实现)** | P0 | T-008, T-009, T-011 | 1. page.tsx 创建单页应用
2. 品牌头部: Logo + "KOL Insight" + "秒思AI制作"
3. 查询区域集成 QueryForm
4. 结果区域集成 ResultTable 和 ExportButton
5. Footer: "© 2026 秒思AI制作"
6. 页面状态管理: 默认态/输入态/查询中/结果态/空结果态/错误态
7. 空状态组件: 引导文案 + 空盒子图标
8. 错误状态组件: 错误提示 + 重试按钮
9. **粗略实现说明**: 重点在功能集成,UI可简化,品牌元素必须保留 | ## 4. Phase 3 任务 - 优化与测试 @@ -334,6 +334,34 @@ async with httpx.AsyncClient() as client: --- + +## 12. 迭代任务 + +### 12.1 Bug 修复 + +| ID | 任务 | 描述 | 依赖 | 优先级 | 验收标准 | +|----|------|------|------|--------|----------| +| T-019 | 修复品牌API响应解析 | 品牌API返回的data是数组结构,当前代码按字典解析导致取不到brand_name | T-007 | P0 | 1. 正确解析 `data[0].brand_name` 获取品牌名称
2. 处理 data 数组为空的边界情况
3. 更新测试用例的 mock 数据结构 | +| T-020 | 添加品牌API认证 | 品牌API需要Bearer Token认证,当前代码未配置 | T-019 | P0 | 1. 新增环境变量 `BRAND_API_TOKEN`
2. 请求时添加 `Authorization: Bearer {token}` 头
3. 更新 `.env.example` 配置示例
4. 更新测试用例验证认证头 | + +| T-027 | 修复巨量云图API调用参数 | API调用不通,日期格式/Cookie头/industry_id等参数错误 | T-023 | P0 | 1. **日期格式**: 从 `YYYY-MM-DD` 改为 `YYYYMMDD`
2. **Cookie头**: 直接使用 `auth_token` 完整值(已含sessionid=xxx)
3. **industry_id**: 使用数据库中视频的industry_id,传字符串格式 `["12"]`
4. **Cookie获取**: 随机选取任意一组aadvid/auth_token,不按brand_id匹配
5. 更新测试用例验证参数格式
6. **TDD要求**: 测试实际API调用成功返回数据 | + + +### 12.2 功能迭代 - 视频分析模块 + +| ID | 任务 | 描述 | 依赖 | 优先级 | 验收标准 | +|----|------|------|------|--------|----------| +| T-021 | SessionID池服务 | 实现从内部API获取Cookie列表,随机选取sessionid | T-004 | P0 | 1. 调用 `/v1/yuntu/get_cookie` 获取100个sessionid
2. 随机选取机制实现
3. 环境变量 `YUNTU_API_TOKEN` 配置
4. **TDD要求**: 先写测试用例(mock API响应) | +| T-022 | SessionID自动重试 | sessionid失效时自动切换到下一个重试 | T-021 | P0 | 1. 检测401/403状态码触发重试
2. 最多重试3次
3. 重试日志记录
4. **TDD要求**: 测试覆盖重试场景 | +| T-023 | 巨量云图API封装 | 封装GetContentMaterialAnalysisInfo接口调用 | T-022 | P0 | 1. 正确构造请求参数(object_id/start_date/end_date/industry_id_list)
2. end_date = start_date + 30天
3. Cookie头设置sessionid
4. 超时10秒
5. **TDD要求**: 测试参数构造和响应解析 | +| T-024 | 视频分析数据接口 | 实现 GET /api/v1/videos/{item_id}/analysis | T-023 | P0 | 1. 从数据库获取基础信息
2. 调用巨量云图API获取实时数据
3. 返回6大类指标结构
4. 计算成本指标(CPM/CPA3/CPsearch等)
5. 除零检查返回null
6. **TDD要求**: 测试覆盖率100% | +| T-025 | 数据库A3指标更新 | 从API获取数据后更新数据库对应字段 | T-024 | P1 | 1. 更新 total_new_a3_cnt
2. 更新 heated_new_a3_cnt
3. 更新 natural_new_a3_cnt
4. 更新 total_cost
5. **TDD要求**: 测试数据库更新逻辑 | +| T-026 | 视频分析前端页面 | 前端展示6大类25+指标(粗略实现) | T-024 | P1 | 1. 基础信息展示(8字段)
2. 触达指标展示(7字段)
3. A3指标展示(3字段)
4. 搜索指标展示(5字段)
5. 费用指标展示(3字段)
6. 成本指标展示(6字段)
7. 数值格式化(千分位/2位小数)
8. **粗略实现**: 功能可用即可 | + +--- + **文档状态**: 待执行 -**建议下一步**: 按顺序执行 Phase 1 任务,完成基础架构搭建 +**建议下一步**: +- **最高优先级**: 执行 T-027 修复巨量云图API调用参数问题 +- 然后验证视频分析功能 T-021~T-026 是否正常工作 **评审建议**: 可运行 `/rt` 对任务列表进行评审 diff --git a/frontend/package.json b/frontend/package.json index e5e794f..cc7bb7b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index baf1dd6..6a79ea6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@ant-design/icons': + specifier: ^6.1.0 + version: 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + antd: + specifier: ^6.2.2 + version: 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next: specifier: 14.2.35 version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -55,6 +61,45 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ant-design/colors@8.0.1': + resolution: {integrity: sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==} + + '@ant-design/cssinjs-utils@2.0.2': + resolution: {integrity: sha512-Mq3Hm6fJuQeFNKSp3+yT4bjuhVbdrsyXE2RyfpJFL0xiYNZdaJ6oFaE3zFrzmHbmvTd2Wp3HCbRtkD4fU+v2ZA==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + '@ant-design/cssinjs@2.0.3': + resolution: {integrity: sha512-HAo8SZ3a6G8v6jT0suCz1270na6EA3obeJWM4uzRijBhdwdoMAXWK2f4WWkwB28yUufsfk3CAhN1coGPQq4kNQ==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/fast-color@3.0.1': + resolution: {integrity: sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==} + engines: {node: '>=8.x'} + + '@ant-design/icons-svg@4.4.2': + resolution: {integrity: sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==} + + '@ant-design/icons@6.1.0': + resolution: {integrity: sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/react-slick@2.0.0': + resolution: {integrity: sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==} + peerDependencies: + react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -64,6 +109,12 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@emotion/unitless@0.7.5': + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -195,6 +246,289 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@rc-component/async-validator@5.1.0': + resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==} + engines: {node: '>=14.x'} + + '@rc-component/cascader@1.11.0': + resolution: {integrity: sha512-VDiEsskThWi8l0/1Nquc9I4ytcMKQYAb9Jkm6wiX5O5fpcMRsm+b8OulBMbr/b4rFTl/2y2y4GdKqQ+2whD+XQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/checkbox@1.0.1': + resolution: {integrity: sha512-08yTH8m+bSm8TOqbybbJ9KiAuIATti6bDs2mVeSfu4QfEnyeF6X0enHVvD1NEAyuBWEAo56QtLe++MYs2D9XiQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/collapse@1.2.0': + resolution: {integrity: sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/color-picker@3.0.3': + resolution: {integrity: sha512-V7gFF9O7o5XwIWafdbOtqI4BUUkEUkgdBwp6favy3xajMX/2dDqytFaiXlcwrpq6aRyPLp5dKLAG5RFKLXMeGA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/context@2.0.1': + resolution: {integrity: sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/dialog@1.8.2': + resolution: {integrity: sha512-CwDSjpjZ1FcgsdKFPuSoYfi9Vbt2bp+ak4Pzkwq4APQC8DopJKWetRu1V+HE9vI1CNAeqvT5WAvAxE6RiDhl7A==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/drawer@1.4.1': + resolution: {integrity: sha512-kNJQie/QjJO5wGeWrZQwSGeuo8staxXx1nYN+dpK2UY7i8teo5PQdZ6ukKSnnW9vmPXsLn3F5nKYRbf43e8+5g==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/dropdown@1.0.2': + resolution: {integrity: sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + '@rc-component/form@1.6.2': + resolution: {integrity: sha512-OgIn2RAoaSBqaIgzJf/X6iflIa9LpTozci1lagLBdURDFhGA370v0+T0tXxOi8YShMjTha531sFhwtnrv+EJaQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/image@1.6.0': + resolution: {integrity: sha512-tSfn2ZE/oP082g4QIOxeehkmgnXB7R+5AFj/lIFr4k7pEuxHBdyGIq9axoCY9qea8NN0DY6p4IB/F07tLqaT5A==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/input-number@1.6.2': + resolution: {integrity: sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/input@1.1.2': + resolution: {integrity: sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@rc-component/mentions@1.6.0': + resolution: {integrity: sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/menu@1.2.0': + resolution: {integrity: sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mini-decimal@1.1.0': + resolution: {integrity: sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==} + engines: {node: '>=8.x'} + + '@rc-component/motion@1.1.6': + resolution: {integrity: sha512-aEQobs/YA0kqRvHIPjQvOytdtdRVyhf/uXAal4chBjxDu6odHckExJzjn2D+Ju1aKK6hx3pAs6BXdV9+86xkgQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mutate-observer@2.0.1': + resolution: {integrity: sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/notification@1.2.0': + resolution: {integrity: sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/overflow@1.0.0': + resolution: {integrity: sha512-GSlBeoE0XTBi5cf3zl8Qh7Uqhn7v8RrlJ8ajeVpEkNe94HWy5l5BQ0Mwn2TVUq9gdgbfEMUmTX7tJFAg7mz0Rw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/pagination@1.2.0': + resolution: {integrity: sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/picker@1.9.0': + resolution: {integrity: sha512-OLisdk8AWVCG9goBU1dWzuH5QlBQk8jktmQ6p0/IyBFwdKGwyIZOSjnBYo8hooHiTdl0lU+wGf/OfMtVBw02KQ==} + engines: {node: '>=12.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: '>= 1.x' + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + + '@rc-component/portal@2.2.0': + resolution: {integrity: sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ==} + engines: {node: '>=12.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/progress@1.0.2': + resolution: {integrity: sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/qrcode@1.1.1': + resolution: {integrity: sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/rate@1.0.1': + resolution: {integrity: sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/resize-observer@1.1.1': + resolution: {integrity: sha512-NfXXMmiR+SmUuKE1NwJESzEUYUFWIDUn2uXpxCTOLwiRUUakd62DRNFjRJArgzyFW8S5rsL4aX5XlyIXyC/vRA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/segmented@1.3.0': + resolution: {integrity: sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@rc-component/select@1.5.2': + resolution: {integrity: sha512-7wqD5D4I2+fc5XoB4nzDDK656QPlDnFAUaxLljkU1wwSpi4+MZxndv9vgg7NQfveuuf0/ilUdOjuPg7NPl7Mmg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + '@rc-component/slider@1.0.1': + resolution: {integrity: sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/steps@1.2.2': + resolution: {integrity: sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/switch@1.0.3': + resolution: {integrity: sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/table@1.9.1': + resolution: {integrity: sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/tabs@1.7.0': + resolution: {integrity: sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/textarea@1.1.2': + resolution: {integrity: sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tooltip@1.4.0': + resolution: {integrity: sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/tour@2.3.0': + resolution: {integrity: sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tree-select@1.6.0': + resolution: {integrity: sha512-UvEGmZT+gcVvRwImAZg3/sXw9nUdn4FmCs1rSIMWjEXEIAo0dTGmIyWuLCvs+1rGe9AZ7CHMPiQUEbdadwV0fw==} + peerDependencies: + react: '*' + react-dom: '*' + + '@rc-component/tree@1.1.0': + resolution: {integrity: sha512-HZs3aOlvFgQdgrmURRc/f4IujiNBf4DdEeXUlkS0lPoLlx9RoqsZcF0caXIAMVb+NaWqKtGQDnrH8hqLCN5zlA==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + '@rc-component/trigger@3.9.0': + resolution: {integrity: sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/upload@1.1.0': + resolution: {integrity: sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/util@1.7.0': + resolution: {integrity: sha512-tIvIGj4Vl6fsZFvWSkYw9sAfiCKUXMyhVz6kpKyZbwyZyRPqv2vxYZROdaO1VB4gqTNvUZFXh6i3APUiterw5g==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rc-component/virtual-list@1.0.2': + resolution: {integrity: sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -413,6 +747,12 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + antd@6.2.2: + resolution: {integrity: sha512-f5RvWnhjt2gZTpBMW3msHwA3IeaCJBHDwVyEsskYGp0EXcRhhklWrltkybDki0ysBNywkjLPp3wuuWhIKfplcQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -536,6 +876,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -547,6 +891,9 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -577,6 +924,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -1027,6 +1377,9 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-mobile@5.0.0: + resolution: {integrity: sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==} + is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -1113,6 +1466,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -1402,6 +1758,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -1464,6 +1823,9 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1528,6 +1890,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1588,6 +1953,9 @@ packages: babel-plugin-macros: optional: true + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1616,6 +1984,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1728,6 +2100,54 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ant-design/colors@8.0.1': + dependencies: + '@ant-design/fast-color': 3.0.1 + + '@ant-design/cssinjs-utils@2.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/cssinjs': 2.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@babel/runtime': 7.28.6 + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/cssinjs@2.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@emotion/hash': 0.8.0 + '@emotion/unitless': 0.7.5 + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + csstype: 3.2.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + stylis: 4.3.6 + + '@ant-design/fast-color@3.0.1': {} + + '@ant-design/icons-svg@4.4.2': {} + + '@ant-design/icons@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/colors': 8.0.1 + '@ant-design/icons-svg': 4.4.2 + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/react-slick@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + clsx: 2.1.1 + json2mq: 0.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + throttle-debounce: 5.0.2 + + '@babel/runtime@7.28.6': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -1744,6 +2164,10 @@ snapshots: tslib: 2.8.1 optional: true + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -1859,6 +2283,352 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@rc-component/async-validator@5.1.0': + dependencies: + '@babel/runtime': 7.28.6 + + '@rc-component/cascader@1.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/select': 1.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tree': 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/checkbox@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/collapse@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/color-picker@3.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/fast-color': 3.0.1 + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/context@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/dialog@1.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/portal': 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/drawer@1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/portal': 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/dropdown@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/trigger': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/form@1.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/async-validator': 5.1.0 + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/image@1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/portal': 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/input-number@1.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/mini-decimal': 1.1.0 + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/input@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/mentions@1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/input': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/menu': 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/textarea': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/menu@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/overflow': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/mini-decimal@1.1.0': + dependencies: + '@babel/runtime': 7.28.6 + + '@rc-component/motion@1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/mutate-observer@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/notification@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/overflow@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@rc-component/resize-observer': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/pagination@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/picker@1.9.0(dayjs@1.11.19)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/overflow': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + dayjs: 1.11.19 + + '@rc-component/portal@2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/progress@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/qrcode@1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/rate@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/resize-observer@1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/segmented@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/select@1.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/overflow': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/virtual-list': 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/slider@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/steps@1.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/switch@1.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/table@1.9.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/context': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/virtual-list': 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tabs@1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/dropdown': 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/menu': 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/textarea@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/input': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tooltip@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/trigger': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tour@2.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/portal': 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tree-select@1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/select': 1.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tree': 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tree@1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/virtual-list': 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/trigger@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/portal': 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/upload@1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/util@1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + is-mobile: 5.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + + '@rc-component/virtual-list@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@rc-component/resize-observer': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.15.0': {} @@ -2067,6 +2837,63 @@ snapshots: ansi-styles@6.2.3: {} + antd@6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@ant-design/colors': 8.0.1 + '@ant-design/cssinjs': 2.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/cssinjs-utils': 2.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/fast-color': 3.0.1 + '@ant-design/icons': 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/react-slick': 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@babel/runtime': 7.28.6 + '@rc-component/cascader': 1.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/checkbox': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/collapse': 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/color-picker': 3.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/dialog': 1.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/drawer': 1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/dropdown': 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/form': 1.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/image': 1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/input': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/input-number': 1.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/mentions': 1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/menu': 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/motion': 1.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/mutate-observer': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/notification': 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/pagination': 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/picker': 1.9.0(dayjs@1.11.19)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/progress': 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/qrcode': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/rate': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/resize-observer': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/segmented': 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/select': 1.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/slider': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/steps': 1.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/switch': 1.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/table': 1.9.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tabs': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/textarea': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tooltip': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tour': 2.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tree': 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tree-select': 1.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/upload': 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/util': 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + dayjs: 1.11.19 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + scroll-into-view-if-needed: 3.1.0 + throttle-debounce: 5.0.2 + transitivePeerDependencies: + - date-fns + - luxon + - moment + any-promise@1.3.0: {} anymatch@3.1.3: @@ -2222,6 +3049,8 @@ snapshots: client-only@0.0.1: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2230,6 +3059,8 @@ snapshots: commander@4.1.1: {} + compute-scroll-into-view@3.1.1: {} + concat-map@0.0.1: {} cross-spawn@7.0.6: @@ -2262,6 +3093,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.19: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -2419,8 +3252,8 @@ snapshots: '@typescript-eslint/parser': 8.54.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -2443,7 +3276,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -2454,22 +3287,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.54.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -2480,7 +3313,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -2869,6 +3702,8 @@ snapshots: is-map@2.0.3: {} + is-mobile@5.0.0: {} + is-negative-zero@2.0.3: {} is-number-object@1.1.1: @@ -2952,6 +3787,10 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json2mq@0.2.0: + dependencies: + string-convert: 0.2.1 + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -3221,6 +4060,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.3.1: {} + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -3302,6 +4143,10 @@ snapshots: dependencies: loose-envify: 1.4.0 + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + semver@6.3.1: {} semver@7.7.3: {} @@ -3375,6 +4220,8 @@ snapshots: streamsearch@1.1.0: {} + string-convert@0.2.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3454,6 +4301,8 @@ snapshots: client-only: 0.0.1 react: 18.3.1 + stylis@4.3.6: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -3508,6 +4357,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throttle-debounce@5.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index d948f12..d8e2341 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -16,7 +16,7 @@ const geistMono = localFont({ export const metadata: Metadata = { title: 'KOL Insight - 云图数据查询分析', - description: 'KOL 视频数据查询与成本分析工具 - 麦秒思AI制作', + description: 'KOL 视频数据查询与成本分析工具 - 秒思AI制作', }; export default function RootLayout({ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 128cc3b..b9b0749 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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('default'); - const [data, setData] = useState([]); - const [total, setTotal] = useState(0); - const [error, setError] = useState(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 ( -
- {/* 查询区域 */} -
- -
- - {/* 结果区域 */} -
- {/* 默认态 */} - {pageState === 'default' && ( -
-
🔍
-

请选择查询方式并输入查询条件

- - 或前往视频分析页面 → - -
- )} - - {/* 加载态 */} - {pageState === 'loading' && ( -
-
-

正在查询数据,请稍候...

-
- )} - - {/* 结果态 */} - {pageState === 'result' && ( -
-
-

查询结果

- 0} /> -
- -
- )} - - {/* 空结果态 */} - {pageState === 'empty' && ( -
-
📦
-

未找到匹配数据

-

请调整查询条件后重新尝试

- -
- )} - - {/* 错误态 */} - {pageState === 'error' && ( -
-
-

查询失败,请重试

-

{error || '可能原因:网络异常或数据库连接失败'}

- -
- )} -
-
- ); + return ; } diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 87558a2..88d6404 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -2,7 +2,7 @@ export default function Footer() { return (
- © 2026 麦秒思AI制作 | KOL Insight v1.0 + © 2026 秒思AI制作 | KOL Insight v1.0
); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 98a94a3..8649c41 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -5,13 +5,13 @@ export default function Header() {
- 麦秒思AI Logo + 秒思AI Logo

KOL Insight

云图数据查询分析

-
麦秒思AI制作
+
秒思AI制作
); diff --git a/frontend/src/components/VideoAnalysis.tsx b/frontend/src/components/VideoAnalysis.tsx index ea3bbf0..a2a0eed 100644 --- a/frontend/src/components/VideoAnalysis.tsx +++ b/frontend/src/components/VideoAnalysis.tsx @@ -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 = { + 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 ( -
-
{label}
-
- {value} - {unit && {unit}} -
-
- ); -} + + {loading ? ( +
+ +
+ ) : data ? ( + + {/* 基础信息 */} + + {data.base_info.star_nickname || '-'} + {data.base_info.star_unique_id || '-'} + {data.base_info.vid || '-'} + {data.base_info.create_date || '-'} + {data.base_info.hot_type || '-'} + {data.base_info.industry_id || '-'} + {data.base_info.brand_name || data.base_info.brand_id || '-'} + + {data.base_info.video_url ? ( + + {data.base_info.title || '查看视频'} + + ) : ( + data.base_info.title || '-' + )} + + -// 指标分组组件 -function MetricGroup({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

-
- {children} -
-
+ {/* 触达指标 */} + + {formatInt(data.reach_metrics.natural_play_cnt)} + {formatInt(data.reach_metrics.heated_play_cnt)} + {formatInt(data.reach_metrics.total_play_cnt)} + {formatInt(data.reach_metrics.total_interaction_cnt)} + {formatInt(data.reach_metrics.digg_cnt)} + {formatInt(data.reach_metrics.share_cnt)} + {formatInt(data.reach_metrics.comment_cnt)} + + + {/* A3指标 */} + + {formatInt(data.a3_metrics.total_new_a3_cnt)} + {formatInt(data.a3_metrics.heated_new_a3_cnt)} + {formatInt(data.a3_metrics.natural_new_a3_cnt)} + + + {/* 搜索指标 */} + + {formatInt(data.search_metrics.back_search_uv)} + {formatInt(data.search_metrics.back_search_cnt)} + {formatInt(data.search_metrics.after_view_search_uv)} + {formatInt(data.search_metrics.after_view_search_cnt)} + {formatNumber(data.search_metrics.estimated_natural_search_uv)} + + + {/* 费用指标 */} + + {formatNumber(data.cost_metrics.total_cost)} + {formatNumber(data.cost_metrics.heated_cost)} + {formatNumber(data.cost_metrics.estimated_video_cost)} + + + {/* 成本指标 */} + + {formatNumber(data.calculated_metrics.estimated_cpm)} + {formatNumber(data.calculated_metrics.estimated_natural_cpm)} + {formatNumber(data.calculated_metrics.estimated_cp_a3)} + {formatNumber(data.calculated_metrics.estimated_natural_cp_a3)} + {formatNumber(data.calculated_metrics.estimated_cp_search)} + {formatNumber(data.calculated_metrics.estimated_natural_cp_search)} + +
+ ) : null} +
); } export default function VideoAnalysis() { - const [itemId, setItemId] = useState(''); + const [searchType, setSearchType] = useState('star_id'); + const [searchValue, setSearchValue] = useState(''); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [data, setData] = useState(null); + const [listData, setListData] = useState([]); + + // 详情弹窗状态 + const [detailVisible, setDetailVisible] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [detailData, setDetailData] = useState(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 = [ + { + 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 ? ( + + {text || '查看视频'} + + ) : ( + 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) => ( + + ), + }, + ]; + return ( -
- {/* 搜索框 */} -
-

视频分析

-
- 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()} +
+ {/* 搜索区域 */} + +

KOL 视频分析

+ +