feat: 审核体系全面改造 — 多维度评分 + 卖点优先级 + AI 语义匹配 + 品牌方 AI 状态通知
后端: - 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度) - 卖点优先级从 required:bool 改为三级 (core/recommended/reference) - AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析 - BriefMatchDetail 评分详情 (覆盖率+亮点+问题点) - min_selling_points 代理商可配置最少卖点数 + Alembic 迁移 - AI 语境复核过滤误报 - Brief AI 解析 + 规则 AI 解析 - AI 未配置/异常时通知品牌方 - 种子数据更新 (新格式审核结果+brief_match_detail) 前端: - 三端审核页面展示四维度评分卡片 - 卖点编辑改为三级优先级选择器 - BriefMatchDetail 展示 (覆盖率进度条+亮点+问题) - min_selling_points 配置 UI - AI 配置页未配置时静默处理 - 文件预览/下载/签名 URL 优化 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0c59797d5b
commit
0ef7650c09
@ -0,0 +1,25 @@
|
||||
"""add min_selling_points to briefs
|
||||
|
||||
Revision ID: 261778c01ef8
|
||||
Revises: 008
|
||||
Create Date: 2026-02-11 18:16:59.557746
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '261778c01ef8'
|
||||
down_revision: Union[str, None] = '008'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('briefs', sa.Column('min_selling_points', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('briefs', 'min_selling_points')
|
||||
@ -1,8 +1,12 @@
|
||||
"""
|
||||
Brief API
|
||||
项目 Brief 文档的 CRUD
|
||||
项目 Brief 文档的 CRUD + AI 解析
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
@ -21,6 +25,8 @@ from app.schemas.brief import (
|
||||
)
|
||||
from app.services.auth import generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/brief", tags=["Brief"])
|
||||
|
||||
|
||||
@ -75,6 +81,7 @@ def _brief_to_response(brief: Brief) -> BriefResponse:
|
||||
file_url=brief.file_url,
|
||||
file_name=brief.file_name,
|
||||
selling_points=brief.selling_points,
|
||||
min_selling_points=brief.min_selling_points,
|
||||
blacklist_words=brief.blacklist_words,
|
||||
competitors=brief.competitors,
|
||||
brand_tone=brief.brand_tone,
|
||||
@ -192,9 +199,10 @@ async def update_brief_agency_attachments(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新 Brief 代理商附件(代理商操作)
|
||||
"""更新 Brief 代理商配置(代理商操作)
|
||||
|
||||
代理商只能更新 agency_attachments 字段,不能修改品牌方设置的其他 Brief 内容。
|
||||
代理商可更新:agency_attachments、selling_points、blacklist_words。
|
||||
不能修改品牌方设置的核心 Brief 内容(文件、时长、竞品等)。
|
||||
"""
|
||||
# 权限检查:代理商必须属于该项目
|
||||
result = await db.execute(
|
||||
@ -234,7 +242,7 @@ async def update_brief_agency_attachments(
|
||||
if not brief:
|
||||
raise HTTPException(status_code=404, detail="Brief 不存在")
|
||||
|
||||
# 仅更新 agency_attachments
|
||||
# 更新代理商可编辑的字段
|
||||
update_fields = request.model_dump(exclude_unset=True)
|
||||
for field, value in update_fields.items():
|
||||
setattr(brief, field, value)
|
||||
@ -243,3 +251,226 @@ async def update_brief_agency_attachments(
|
||||
await db.refresh(brief)
|
||||
|
||||
return _brief_to_response(brief)
|
||||
|
||||
|
||||
# ==================== AI 解析 ====================
|
||||
|
||||
class BriefParseResponse(BaseModel):
|
||||
"""Brief AI 解析响应"""
|
||||
product_name: str = ""
|
||||
target_audience: str = ""
|
||||
content_requirements: str = ""
|
||||
selling_points: list[dict] = []
|
||||
blacklist_words: list[dict] = []
|
||||
|
||||
|
||||
@router.post("/parse", response_model=BriefParseResponse)
|
||||
async def parse_brief_with_ai(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
AI 解析 Brief 文档
|
||||
|
||||
从品牌方上传的 Brief 文件中提取结构化信息:
|
||||
- 产品名称
|
||||
- 目标人群
|
||||
- 内容要求
|
||||
- 卖点建议
|
||||
- 违禁词建议
|
||||
"""
|
||||
# 权限检查(代理商需要属于该项目)
|
||||
project = await _get_project_with_permission(project_id, current_user, db)
|
||||
|
||||
# 获取 Brief
|
||||
result = await db.execute(
|
||||
select(Brief)
|
||||
.options(selectinload(Brief.project))
|
||||
.where(Brief.project_id == project_id)
|
||||
)
|
||||
brief = result.scalar_one_or_none()
|
||||
if not brief:
|
||||
raise HTTPException(status_code=404, detail="Brief 不存在,请先让品牌方创建 Brief")
|
||||
|
||||
# 收集所有可解析的文档 URL
|
||||
documents: list[dict] = [] # [{"url": ..., "name": ...}]
|
||||
|
||||
if brief.file_url and brief.file_name:
|
||||
documents.append({"url": brief.file_url, "name": brief.file_name})
|
||||
|
||||
if brief.attachments:
|
||||
for att in brief.attachments:
|
||||
if att.get("url") and att.get("name"):
|
||||
documents.append({"url": att["url"], "name": att["name"]})
|
||||
|
||||
if not documents:
|
||||
raise HTTPException(status_code=400, detail="Brief 没有可解析的文件")
|
||||
|
||||
# 提取文本(每个文档限时 60 秒)
|
||||
import asyncio
|
||||
from app.services.document_parser import DocumentParser
|
||||
|
||||
all_texts = []
|
||||
for doc in documents:
|
||||
try:
|
||||
text = await asyncio.wait_for(
|
||||
DocumentParser.download_and_parse(doc["url"], doc["name"]),
|
||||
timeout=60.0,
|
||||
)
|
||||
if text and text.strip():
|
||||
all_texts.append(f"=== {doc['name']} ===\n{text}")
|
||||
logger.info(f"成功解析文档 {doc['name']},提取 {len(text)} 字符")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"解析文档 {doc['name']} 超时(60s),已跳过")
|
||||
except Exception as e:
|
||||
logger.warning(f"解析文档 {doc['name']} 失败: {e}")
|
||||
|
||||
if not all_texts:
|
||||
raise HTTPException(status_code=400, detail="所有文档均解析失败,无法提取文本内容")
|
||||
|
||||
combined_text = "\n\n".join(all_texts)
|
||||
|
||||
# 截断过长文本
|
||||
max_chars = 15000
|
||||
if len(combined_text) > max_chars:
|
||||
combined_text = combined_text[:max_chars] + "\n...(内容已截断)"
|
||||
|
||||
# 获取 AI 客户端
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
|
||||
tenant_id = project.brand_id or "default"
|
||||
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||||
if not ai_client:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="AI 服务未配置,请在品牌方设置中配置 AI 服务",
|
||||
)
|
||||
|
||||
config = await AIServiceFactory.get_config(tenant_id, db)
|
||||
text_model = "gpt-4o"
|
||||
if config and config.models:
|
||||
text_model = config.models.get("text", "gpt-4o")
|
||||
|
||||
# AI 解析
|
||||
prompt = f"""你是营销内容合规审核专家。请从以下品牌方 Brief 文档中提取结构化信息。
|
||||
|
||||
文档内容:
|
||||
{combined_text}
|
||||
|
||||
请以 JSON 格式返回,不要包含其他内容:
|
||||
{{
|
||||
"product_name": "产品名称",
|
||||
"target_audience": "目标人群描述",
|
||||
"content_requirements": "内容创作要求的简要总结",
|
||||
"selling_points": [
|
||||
{{"content": "卖点1", "priority": "core"}},
|
||||
{{"content": "卖点2", "priority": "recommended"}},
|
||||
{{"content": "卖点3", "priority": "reference"}}
|
||||
],
|
||||
"blacklist_words": [
|
||||
{{"word": "违禁词1", "reason": "原因"}},
|
||||
{{"word": "违禁词2", "reason": "原因"}}
|
||||
]
|
||||
}}
|
||||
|
||||
说明:
|
||||
- product_name: 从文档中识别的产品/品牌名称
|
||||
- target_audience: 目标消费人群
|
||||
- content_requirements: 对达人创作内容的要求(时长、风格、场景等)
|
||||
- selling_points: 产品卖点,priority 说明:
|
||||
- "core": 核心卖点,品牌方重点关注,建议优先传达
|
||||
- "recommended": 推荐卖点,建议提及
|
||||
- "reference": 参考信息,不要求出现在脚本中
|
||||
- blacklist_words: 从文档中识别的需要避免的词语(绝对化用语、竞品名、敏感词等)"""
|
||||
|
||||
last_error = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
response = await ai_client.chat_completion(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
model=text_model,
|
||||
temperature=0.2 if attempt == 0 else 0.1,
|
||||
max_tokens=2000,
|
||||
)
|
||||
|
||||
# 提取 JSON
|
||||
logger.info(f"AI 原始响应 (attempt={attempt}): {response.content[:500]}")
|
||||
content = _extract_json_from_response(response.content)
|
||||
logger.info(f"提取的 JSON: {content[:500]}")
|
||||
parsed = json.loads(content)
|
||||
|
||||
return BriefParseResponse(
|
||||
product_name=parsed.get("product_name", ""),
|
||||
target_audience=parsed.get("target_audience", ""),
|
||||
content_requirements=parsed.get("content_requirements", ""),
|
||||
selling_points=parsed.get("selling_points", []),
|
||||
blacklist_words=parsed.get("blacklist_words", []),
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
last_error = e
|
||||
logger.warning(f"AI 返回内容非 JSON (attempt={attempt}): {e}, raw={response.content[:300]}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"AI 解析 Brief 失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"AI 解析失败: {str(e)[:200]}")
|
||||
|
||||
# 两次都失败
|
||||
logger.error(f"AI 解析 Brief JSON 格式错误,两次重试均失败: {last_error}")
|
||||
raise HTTPException(status_code=500, detail="AI 解析结果格式错误,请重试")
|
||||
|
||||
|
||||
def _extract_json_from_response(raw: str) -> str:
|
||||
"""从 AI 响应中提取 JSON 内容(处理 markdown 代码块、中文引号等)"""
|
||||
import re
|
||||
text = raw.strip()
|
||||
|
||||
# 移除 markdown ```json ... ``` 代码块包裹
|
||||
m = re.search(r'```(?:json)?\s*\n(.*?)```', text, re.DOTALL)
|
||||
if m:
|
||||
text = m.group(1).strip()
|
||||
|
||||
# 尝试找到第一个 { 和最后一个 }
|
||||
first_brace = text.find("{")
|
||||
last_brace = text.rfind("}")
|
||||
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
||||
text = text[first_brace:last_brace + 1]
|
||||
|
||||
# 清理中文引号等特殊字符
|
||||
text = _sanitize_json_string(text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _sanitize_json_string(text: str) -> str:
|
||||
"""
|
||||
清理 AI 返回的 JSON 文本中的中文引号等特殊字符。
|
||||
中文引号 "" 在 JSON 字符串值内会破坏解析。
|
||||
"""
|
||||
result = []
|
||||
in_string = False
|
||||
i = 0
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if ch == '\\' and in_string and i + 1 < len(text):
|
||||
result.append(ch)
|
||||
result.append(text[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"' and not in_string:
|
||||
in_string = True
|
||||
result.append(ch)
|
||||
elif ch == '"' and in_string:
|
||||
in_string = False
|
||||
result.append(ch)
|
||||
elif in_string and ch in '\u201c\u201d\u300c\u300d':
|
||||
# 中文引号 "" 和「」 → 单引号
|
||||
result.append("'")
|
||||
elif not in_string and ch in '\u201c\u201d':
|
||||
# JSON 结构层的中文引号 → 英文双引号
|
||||
result.append('"')
|
||||
else:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
return ''.join(result)
|
||||
|
||||
@ -141,6 +141,7 @@ async def create_project(
|
||||
sender_name=brand.name,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return await _project_to_response(project, db)
|
||||
|
||||
|
||||
@ -354,6 +355,7 @@ async def assign_agencies(
|
||||
sender_name=brand.name,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return await _project_to_response(project, db)
|
||||
|
||||
|
||||
|
||||
@ -131,10 +131,14 @@ _platform_rules = {
|
||||
"xiaohongshu": {
|
||||
"platform": "xiaohongshu",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "绝对", "100%"]},
|
||||
{"type": "forbidden_word", "words": [
|
||||
"最好", "绝对", "100%", "第一", "最佳", "国家级", "顶级",
|
||||
"万能", "神器", "秒杀", "碾压", "永久", "根治",
|
||||
"一次见效", "立竿见影", "无副作用",
|
||||
]},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-10T00:00:00Z",
|
||||
"version": "2024.06",
|
||||
"updated_at": "2024-06-15T00:00:00Z",
|
||||
},
|
||||
"bilibili": {
|
||||
"platform": "bilibili",
|
||||
@ -336,6 +340,33 @@ async def add_to_whitelist(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/whitelist/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_whitelist_item(
|
||||
item_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除白名单项"""
|
||||
result = await db.execute(
|
||||
select(WhitelistItem).where(
|
||||
and_(
|
||||
WhitelistItem.id == item_id,
|
||||
WhitelistItem.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"白名单项不存在: {item_id}",
|
||||
)
|
||||
|
||||
await db.delete(item)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ==================== 竞品库 ====================
|
||||
|
||||
@router.get("/competitors", response_model=CompetitorListResponse)
|
||||
@ -1012,6 +1043,35 @@ async def get_forbidden_words_for_tenant(
|
||||
]
|
||||
|
||||
|
||||
async def get_competitors_for_brand(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
获取品牌方配置的竞品列表
|
||||
|
||||
Returns:
|
||||
[{"name": "竞品名", "keywords": ["关键词1", ...]}]
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Competitor).where(
|
||||
and_(
|
||||
Competitor.tenant_id == tenant_id,
|
||||
Competitor.brand_id == brand_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
competitors = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"name": c.name,
|
||||
"keywords": c.keywords or [],
|
||||
}
|
||||
for c in competitors
|
||||
]
|
||||
|
||||
|
||||
async def get_active_platform_rules(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,13 +2,15 @@
|
||||
任务 API
|
||||
实现完整的审核任务流程
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.database import get_db, AsyncSessionLocal
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.task import Task, TaskStage, TaskStatus
|
||||
from app.models.project import Project
|
||||
@ -41,6 +43,7 @@ from app.services.task_service import (
|
||||
check_task_permission,
|
||||
upload_script,
|
||||
upload_video,
|
||||
complete_ai_review,
|
||||
agency_review,
|
||||
brand_review,
|
||||
submit_appeal,
|
||||
@ -53,10 +56,350 @@ from app.services.task_service import (
|
||||
)
|
||||
from app.api.sse import notify_new_task, notify_task_updated, notify_review_decision
|
||||
from app.services.message_service import create_message
|
||||
from app.models.brief import Brief
|
||||
from app.schemas.review import ScriptReviewRequest, Platform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["任务"])
|
||||
|
||||
|
||||
async def _run_script_ai_review(task_id: str, tenant_id: str):
|
||||
"""
|
||||
后台执行脚本 AI 审核
|
||||
|
||||
- 获取 Brief 信息(卖点、黑名单词)
|
||||
- 调用 review_script 进行审核
|
||||
- 保存审核结果并推进任务阶段
|
||||
- 发送 SSE 通知
|
||||
"""
|
||||
from app.api.scripts import review_script
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
task = await get_task_by_id(db, task_id)
|
||||
if not task or task.stage.value != "script_ai_review":
|
||||
logger.warning(f"任务 {task_id} 不在 AI 审核阶段,跳过")
|
||||
return
|
||||
|
||||
# 获取项目信息
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == task.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if not project:
|
||||
logger.error(f"任务 {task_id} 对应的项目不存在")
|
||||
return
|
||||
|
||||
# 获取 Brief
|
||||
brief_result = await db.execute(
|
||||
select(Brief).where(Brief.project_id == project.id)
|
||||
)
|
||||
brief = brief_result.scalar_one_or_none()
|
||||
|
||||
# 构建审核请求
|
||||
platform = project.platform or "douyin"
|
||||
selling_points = brief.selling_points if brief else None
|
||||
blacklist_words = brief.blacklist_words if brief else None
|
||||
min_selling_points = brief.min_selling_points if brief else None
|
||||
|
||||
request = ScriptReviewRequest(
|
||||
content=" ", # 占位,实际内容从 file_url 解析
|
||||
platform=Platform(platform),
|
||||
brand_id=project.brand_id,
|
||||
selling_points=selling_points,
|
||||
min_selling_points=min_selling_points,
|
||||
blacklist_words=blacklist_words,
|
||||
file_url=task.script_file_url,
|
||||
file_name=task.script_file_name,
|
||||
)
|
||||
|
||||
# 调用审核逻辑
|
||||
result = await review_script(
|
||||
request=request,
|
||||
x_tenant_id=tenant_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# 保存审核结果
|
||||
task = await get_task_by_id(db, task_id)
|
||||
task = await complete_ai_review(
|
||||
db=db,
|
||||
task=task,
|
||||
review_type="script",
|
||||
score=result.score,
|
||||
result=result.model_dump(),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"任务 {task_id} AI 审核完成,得分: {result.score}")
|
||||
|
||||
# SSE 通知达人和代理商
|
||||
try:
|
||||
user_ids = []
|
||||
creator_result = await db.execute(
|
||||
select(Creator).where(Creator.id == task.creator_id)
|
||||
)
|
||||
creator_obj = creator_result.scalar_one_or_none()
|
||||
if creator_obj:
|
||||
user_ids.append(creator_obj.user_id)
|
||||
|
||||
agency_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = agency_result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
user_ids.append(agency_obj.user_id)
|
||||
|
||||
if user_ids:
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=user_ids,
|
||||
data={"action": "ai_review_completed", "stage": task.stage.value, "score": result.score},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 创建消息通知代理商
|
||||
try:
|
||||
ag_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
ag_obj = ag_result.scalar_one_or_none()
|
||||
if ag_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=ag_obj.user_id,
|
||||
type="task",
|
||||
title="脚本 AI 审核完成",
|
||||
content=f"任务「{task.name}」AI 审核完成,综合得分 {result.score} 分,请审核。",
|
||||
related_task_id=task.id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# AI 未配置时通知品牌方
|
||||
if not result.ai_available:
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == project.brand_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj and brand_obj.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="AI 审核降级运行",
|
||||
content=f"任务「{task.name}」的 AI 审核已降级运行(仅关键词检测),请前往「AI 配置」完成设置以获得更精准的审核结果。",
|
||||
related_task_id=task.id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"任务 {task_id} AI 审核失败: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
# AI 审核异常时通知品牌方(rollback 后重新开始事务)
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == tenant_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj and brand_obj.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="AI 审核异常",
|
||||
content=f"任务 AI 审核过程中出错,审核结果可能不完整,请检查 AI 服务配置。错误信息:{str(e)[:100]}",
|
||||
related_task_id=task_id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _run_video_ai_review(task_id: str, tenant_id: str):
|
||||
"""
|
||||
后台执行视频 AI 审核
|
||||
|
||||
复用脚本审核的完整规则检测链(违禁词/竞品/平台规则/白名单/AI深度分析)。
|
||||
审核内容来源:已通过审核的脚本文本 + 视频文件(如可解析)。
|
||||
"""
|
||||
from app.api.scripts import review_script
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await asyncio.sleep(2) # 模拟处理延迟
|
||||
|
||||
task = await get_task_by_id(db, task_id)
|
||||
if not task or task.stage.value != "video_ai_review":
|
||||
logger.warning(f"任务 {task_id} 不在视频 AI 审核阶段,跳过")
|
||||
return
|
||||
|
||||
# 获取项目信息
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == task.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if not project:
|
||||
logger.error(f"任务 {task_id} 对应的项目不存在")
|
||||
return
|
||||
|
||||
# 获取 Brief
|
||||
brief_result = await db.execute(
|
||||
select(Brief).where(Brief.project_id == project.id)
|
||||
)
|
||||
brief = brief_result.scalar_one_or_none()
|
||||
|
||||
platform = project.platform or "douyin"
|
||||
selling_points = brief.selling_points if brief else None
|
||||
blacklist_words = brief.blacklist_words if brief else None
|
||||
min_selling_points = brief.min_selling_points if brief else None
|
||||
|
||||
# 使用脚本内容作为审核基础(视频 ASR 尚未实现,先复用脚本文本)
|
||||
script_content = ""
|
||||
if task.script_file_url and task.script_file_name:
|
||||
# 脚本文件可用,复用
|
||||
pass # review_script 会自动解析 file_url
|
||||
|
||||
request = ScriptReviewRequest(
|
||||
content=script_content or " ",
|
||||
platform=Platform(platform),
|
||||
brand_id=project.brand_id,
|
||||
selling_points=selling_points,
|
||||
min_selling_points=min_selling_points,
|
||||
blacklist_words=blacklist_words,
|
||||
file_url=task.script_file_url,
|
||||
file_name=task.script_file_name,
|
||||
)
|
||||
|
||||
# 调用完整审核逻辑(竞品/违禁词/平台规则/白名单/AI深度分析全部参与)
|
||||
result = await review_script(
|
||||
request=request,
|
||||
x_tenant_id=tenant_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
video_score = result.score
|
||||
video_result = {
|
||||
"score": video_score,
|
||||
"summary": result.summary,
|
||||
"violations": [v.model_dump() for v in result.violations],
|
||||
"soft_warnings": [w.model_dump() for w in result.soft_warnings],
|
||||
"dimensions": result.dimensions.model_dump(),
|
||||
"selling_point_matches": [sp.model_dump() for sp in result.selling_point_matches],
|
||||
}
|
||||
|
||||
task = await get_task_by_id(db, task_id)
|
||||
task = await complete_ai_review(
|
||||
db=db,
|
||||
task=task,
|
||||
review_type="video",
|
||||
score=video_score,
|
||||
result=video_result,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"任务 {task_id} 视频 AI 审核完成,得分: {video_score}")
|
||||
|
||||
# SSE 通知
|
||||
try:
|
||||
user_ids = []
|
||||
creator_result = await db.execute(
|
||||
select(Creator).where(Creator.id == task.creator_id)
|
||||
)
|
||||
creator_obj = creator_result.scalar_one_or_none()
|
||||
if creator_obj:
|
||||
user_ids.append(creator_obj.user_id)
|
||||
|
||||
agency_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = agency_result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
user_ids.append(agency_obj.user_id)
|
||||
|
||||
if user_ids:
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=user_ids,
|
||||
data={"action": "ai_review_completed", "stage": task.stage.value, "score": video_score},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 创建消息通知代理商
|
||||
try:
|
||||
ag_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
ag_obj = ag_result.scalar_one_or_none()
|
||||
if ag_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=ag_obj.user_id,
|
||||
type="task",
|
||||
title="视频 AI 审核完成",
|
||||
content=f"任务「{task.name}」视频 AI 审核完成,得分 {video_score} 分,请审核。",
|
||||
related_task_id=task.id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# AI 未配置时通知品牌方
|
||||
if not result.ai_available:
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == project.brand_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj and brand_obj.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="视频 AI 审核降级运行",
|
||||
content=f"任务「{task.name}」的视频 AI 审核已降级运行(仅关键词检测),请前往「AI 配置」完成设置以获得更精准的审核结果。",
|
||||
related_task_id=task.id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"任务 {task_id} 视频 AI 审核失败: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
# AI 审核异常时通知品牌方(rollback 后重新开始事务)
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == tenant_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj and brand_obj.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="视频 AI 审核异常",
|
||||
content=f"任务视频 AI 审核过程中出错,审核结果可能不完整,请检查 AI 服务配置。错误信息:{str(e)[:100]}",
|
||||
related_task_id=task_id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _task_to_response(task: Task) -> TaskResponse:
|
||||
"""将数据库模型转换为响应模型"""
|
||||
return TaskResponse(
|
||||
@ -175,30 +518,64 @@ async def create_new_task(
|
||||
# 重新加载关联
|
||||
task = await get_task_by_id(db, task.id)
|
||||
|
||||
# 提取通知所需的值(commit 后 ORM 对象会过期,提前缓存)
|
||||
_task_id = task.id
|
||||
_task_name = task.name
|
||||
_project_id = task.project.id
|
||||
_project_name = task.project.name
|
||||
_project_brand_id = task.project.brand_id
|
||||
_agency_name = agency.name
|
||||
_creator_user_id = creator.user_id
|
||||
_creator_name = creator.name or creator.id
|
||||
|
||||
# 创建消息 + SSE 通知达人有新任务
|
||||
try:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=creator.user_id,
|
||||
user_id=_creator_user_id,
|
||||
type="new_task",
|
||||
title="新任务分配",
|
||||
content=f"您有新的任务「{task.name}」,来自项目「{task.project.name}」",
|
||||
related_task_id=task.id,
|
||||
related_project_id=task.project.id,
|
||||
sender_name=agency.name,
|
||||
content=f"您有新的任务「{_task_name}」,来自项目「{_project_name}」",
|
||||
related_task_id=_task_id,
|
||||
related_project_id=_project_id,
|
||||
sender_name=_agency_name,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"创建达人通知消息失败: {e}")
|
||||
|
||||
# 通知品牌方:代理商给项目添加了达人
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == _project_brand_id)
|
||||
)
|
||||
brand = brand_result.scalar_one_or_none()
|
||||
if brand and brand.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand.user_id,
|
||||
type="new_task",
|
||||
title="达人加入项目",
|
||||
content=f"代理商「{_agency_name}」将达人「{_creator_name}」加入项目「{_project_name}」,任务:{_task_name}",
|
||||
related_task_id=_task_id,
|
||||
related_project_id=_project_id,
|
||||
sender_name=_agency_name,
|
||||
)
|
||||
await db.commit()
|
||||
else:
|
||||
logger.warning(f"品牌方不存在或无 user_id: brand_id={_project_brand_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"创建品牌方通知消息失败: {e}")
|
||||
|
||||
try:
|
||||
await notify_new_task(
|
||||
task_id=task.id,
|
||||
creator_user_id=creator.user_id,
|
||||
task_name=task.name,
|
||||
project_name=task.project.name,
|
||||
task_id=_task_id,
|
||||
creator_user_id=_creator_user_id,
|
||||
task_name=_task_name,
|
||||
project_name=_project_name,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"SSE 通知失败: {e}")
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
@ -211,6 +588,7 @@ async def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
stage: Optional[TaskStage] = Query(None),
|
||||
project_id: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@ -243,7 +621,7 @@ async def list_tasks(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="代理商信息不存在",
|
||||
)
|
||||
tasks, total = await list_tasks_for_agency(db, agency.id, page, page_size, stage)
|
||||
tasks, total = await list_tasks_for_agency(db, agency.id, page, page_size, stage, project_id)
|
||||
|
||||
elif current_user.role == UserRole.BRAND:
|
||||
result = await db.execute(
|
||||
@ -255,7 +633,7 @@ async def list_tasks(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="品牌方信息不存在",
|
||||
)
|
||||
tasks, total = await list_tasks_for_brand(db, brand.id, page, page_size, stage)
|
||||
tasks, total = await list_tasks_for_brand(db, brand.id, page, page_size, stage, project_id)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
@ -395,13 +773,23 @@ async def upload_task_script(
|
||||
# 重新加载关联
|
||||
task = await get_task_by_id(db, task.id)
|
||||
|
||||
# SSE 通知代理商脚本已上传
|
||||
# 通知代理商脚本已上传(消息 + SSE)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=agency_obj.user_id,
|
||||
type="task",
|
||||
title="达人已上传脚本",
|
||||
content=f"任务「{task.name}」的脚本已上传,等待 AI 审核。",
|
||||
related_task_id=task.id,
|
||||
sender_name=creator.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[agency_obj.user_id],
|
||||
@ -410,6 +798,18 @@ async def upload_task_script(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取 tenant_id (品牌方 ID) 并在后台触发 AI 审核
|
||||
try:
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == task.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if project:
|
||||
asyncio.create_task(_run_script_ai_review(task.id, project.brand_id))
|
||||
logger.info(f"已触发任务 {task.id} 的后台 AI 审核")
|
||||
except Exception as e:
|
||||
logger.error(f"触发 AI 审核失败: {e}")
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@ -458,13 +858,23 @@ async def upload_task_video(
|
||||
# 重新加载关联
|
||||
task = await get_task_by_id(db, task.id)
|
||||
|
||||
# SSE 通知代理商视频已上传
|
||||
# 通知代理商视频已上传(消息 + SSE)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=agency_obj.user_id,
|
||||
type="task",
|
||||
title="达人已上传视频",
|
||||
content=f"任务「{task.name}」的视频已上传,等待 AI 审核。",
|
||||
related_task_id=task.id,
|
||||
sender_name=creator.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[agency_obj.user_id],
|
||||
@ -473,6 +883,18 @@ async def upload_task_video(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取 tenant_id 并在后台触发视频 AI 审核
|
||||
try:
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == task.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if project:
|
||||
asyncio.create_task(_run_video_ai_review(task.id, project.brand_id))
|
||||
logger.info(f"已触发任务 {task.id} 的后台视频 AI 审核")
|
||||
except Exception as e:
|
||||
logger.error(f"触发视频 AI 审核失败: {e}")
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@ -616,6 +1038,59 @@ async def review_script(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 代理商通过 → 通知品牌方有新内容待审核
|
||||
try:
|
||||
if current_user.role == UserRole.AGENCY and request.action in ("pass", "force_pass"):
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == task.project.brand_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="新脚本待审核",
|
||||
content=f"任务「{task.name}」脚本已通过代理商审核,请进行品牌终审。",
|
||||
related_task_id=task.id,
|
||||
sender_name=current_user.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[brand_obj.user_id],
|
||||
data={"action": "script_pending_brand_review", "stage": task.stage.value},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 品牌方审核 → 通知代理商结果
|
||||
try:
|
||||
if current_user.role == UserRole.BRAND:
|
||||
ag_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
ag_obj = ag_result.scalar_one_or_none()
|
||||
if ag_obj:
|
||||
action_text = {"pass": "通过", "reject": "驳回"}.get(request.action, request.action)
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=ag_obj.user_id,
|
||||
type="task",
|
||||
title=f"脚本品牌终审{action_text}",
|
||||
content=f"任务「{task.name}」脚本品牌终审已{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
|
||||
related_task_id=task.id,
|
||||
sender_name=current_user.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[ag_obj.user_id],
|
||||
data={"action": f"script_brand_{request.action}", "stage": task.stage.value},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@ -756,6 +1231,59 @@ async def review_video(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 代理商通过 → 通知品牌方有视频待审核
|
||||
try:
|
||||
if current_user.role == UserRole.AGENCY and request.action in ("pass", "force_pass"):
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == task.project.brand_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="新视频待审核",
|
||||
content=f"任务「{task.name}」视频已通过代理商审核,请进行品牌终审。",
|
||||
related_task_id=task.id,
|
||||
sender_name=current_user.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[brand_obj.user_id],
|
||||
data={"action": "video_pending_brand_review", "stage": task.stage.value},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 品牌方审核 → 通知代理商结果
|
||||
try:
|
||||
if current_user.role == UserRole.BRAND:
|
||||
ag_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
ag_obj = ag_result.scalar_one_or_none()
|
||||
if ag_obj:
|
||||
action_text = {"pass": "通过", "reject": "驳回"}.get(request.action, request.action)
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=ag_obj.user_id,
|
||||
type="task",
|
||||
title=f"视频品牌终审{action_text}",
|
||||
content=f"任务「{task.name}」视频品牌终审已{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
|
||||
related_task_id=task.id,
|
||||
sender_name=current_user.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[ag_obj.user_id],
|
||||
data={"action": f"video_brand_{request.action}", "stage": task.stage.value},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@ -804,13 +1332,23 @@ async def submit_task_appeal(
|
||||
# 重新加载关联
|
||||
task = await get_task_by_id(db, task.id)
|
||||
|
||||
# SSE 通知代理商有新申诉
|
||||
# 通知代理商有新申诉(消息 + SSE)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=agency_obj.user_id,
|
||||
type="task",
|
||||
title="达人提交申诉",
|
||||
content=f"任务「{task.name}」的达人提交了申诉:{request.reason}",
|
||||
related_task_id=task.id,
|
||||
sender_name=creator.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[agency_obj.user_id],
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""
|
||||
文件上传 API
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form, status
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
@ -135,7 +136,6 @@ class SignedUrlResponse(BaseModel):
|
||||
async def get_signed_url(
|
||||
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||
expire: int = Query(3600, ge=60, le=43200, description="有效期(秒),默认1小时,最长12小时"),
|
||||
download: bool = Query(False, description="是否强制下载(添加 Content-Disposition: attachment)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
@ -158,7 +158,7 @@ async def get_signed_url(
|
||||
)
|
||||
|
||||
try:
|
||||
signed_url = generate_presigned_url(file_key, expire_seconds=expire, download=download)
|
||||
signed_url = generate_presigned_url(file_key, expire_seconds=expire)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@ -171,6 +171,120 @@ async def get_signed_url(
|
||||
)
|
||||
|
||||
|
||||
def _get_tos_object(file_key: str) -> tuple[bytes, str]:
|
||||
"""
|
||||
从 TOS 获取文件内容和文件名(内部工具函数)
|
||||
|
||||
Returns:
|
||||
(content, filename)
|
||||
"""
|
||||
import tos as tos_sdk
|
||||
|
||||
region = settings.TOS_REGION
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
client = tos_sdk.TosClientV2(
|
||||
ak=settings.TOS_ACCESS_KEY_ID,
|
||||
sk=settings.TOS_SECRET_ACCESS_KEY,
|
||||
endpoint=f"https://{endpoint}",
|
||||
region=region,
|
||||
)
|
||||
resp = client.get_object(bucket=settings.TOS_BUCKET_NAME, key=file_key)
|
||||
content = resp.read()
|
||||
|
||||
# 从 file_key 提取文件名,去掉时间戳前缀
|
||||
filename = file_key.split("/")[-1]
|
||||
if "_" in filename and filename.split("_")[0].isdigit():
|
||||
filename = filename.split("_", 1)[1]
|
||||
|
||||
return content, filename
|
||||
|
||||
|
||||
def _resolve_file_key(url: str) -> str:
|
||||
"""从 URL 或 file_key 解析出实际 file_key"""
|
||||
from app.services.oss import parse_file_key_from_url
|
||||
|
||||
file_key = url
|
||||
if url.startswith("http"):
|
||||
file_key = parse_file_key_from_url(url)
|
||||
return file_key
|
||||
|
||||
|
||||
def _guess_content_type(filename: str) -> str:
|
||||
"""根据文件名猜测 MIME 类型"""
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
mime_map = {
|
||||
"pdf": "application/pdf",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"gif": "image/gif",
|
||||
"webp": "image/webp",
|
||||
"mp4": "video/mp4",
|
||||
"mov": "video/quicktime",
|
||||
"webm": "video/webm",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"txt": "text/plain",
|
||||
}
|
||||
return mime_map.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
async def download_file(
|
||||
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
代理下载文件 — 后端获取 TOS 文件后返回给前端,
|
||||
设置 Content-Disposition: attachment 确保浏览器触发下载。
|
||||
"""
|
||||
file_key = _resolve_file_key(url)
|
||||
if not file_key:
|
||||
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||
|
||||
try:
|
||||
content, filename = _get_tos_object(file_key)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"下载文件失败: {e}")
|
||||
|
||||
from fastapi.responses import Response
|
||||
encoded_filename = quote(filename)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/preview")
|
||||
async def preview_file(
|
||||
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
代理预览文件 — 后端获取 TOS 文件后返回给前端,
|
||||
设置正确的 Content-Type 让浏览器可以直接渲染(PDF / 图片等)。
|
||||
"""
|
||||
file_key = _resolve_file_key(url)
|
||||
if not file_key:
|
||||
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||
|
||||
try:
|
||||
content, filename = _get_tos_object(file_key)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"获取文件失败: {e}")
|
||||
|
||||
from fastapi.responses import Response
|
||||
content_type = _guess_content_type(filename)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=content_type,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/proxy", response_model=FileUploadedResponse)
|
||||
async def proxy_upload(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
@ -30,9 +30,12 @@ class Brief(Base, TimestampMixin):
|
||||
file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# 解析后的结构化内容
|
||||
# 卖点要求: [{"content": "SPF50+", "required": true}, ...]
|
||||
# 卖点要求: [{"content": "SPF50+", "priority": "core"}, ...]
|
||||
selling_points: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 代理商要求至少体现的卖点条数(0 或 None 表示不限制)
|
||||
min_selling_points: Mapped[Optional[int]] = mapped_column(nullable=True)
|
||||
|
||||
# 违禁词: [{"word": "最好", "reason": "绝对化用语"}, ...]
|
||||
blacklist_words: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
"""
|
||||
Brief 相关 Schema
|
||||
|
||||
卖点格式 (selling_points: List[dict]):
|
||||
新格式: {"content": "卖点内容", "priority": "core|recommended|reference"}
|
||||
旧格式: {"content": "卖点内容", "required": true|false}
|
||||
兼容规则: required=true → priority="core", required=false → priority="recommended"
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
@ -39,8 +44,13 @@ class BriefUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
class AgencyBriefUpdateRequest(BaseModel):
|
||||
"""代理商更新 Brief 请求(仅允许更新 agency_attachments)"""
|
||||
"""代理商更新 Brief 请求(允许更新代理商附件 + 卖点 + 违禁词 + AI解析内容)"""
|
||||
agency_attachments: Optional[List[dict]] = None
|
||||
selling_points: Optional[List[dict]] = None
|
||||
min_selling_points: Optional[int] = None
|
||||
blacklist_words: Optional[List[dict]] = None
|
||||
brand_tone: Optional[str] = None
|
||||
other_requirements: Optional[str] = None
|
||||
|
||||
|
||||
# ===== 响应 =====
|
||||
@ -53,6 +63,7 @@ class BriefResponse(BaseModel):
|
||||
file_url: Optional[str] = None
|
||||
file_name: Optional[str] = None
|
||||
selling_points: Optional[List[dict]] = None
|
||||
min_selling_points: Optional[int] = None
|
||||
blacklist_words: Optional[List[dict]] = None
|
||||
competitors: Optional[List[str]] = None
|
||||
brand_tone: Optional[str] = None
|
||||
|
||||
@ -91,6 +91,7 @@ class Violation(BaseModel):
|
||||
content: str = Field(..., description="违规内容")
|
||||
severity: RiskLevel = Field(..., description="严重程度")
|
||||
suggestion: str = Field(..., description="修改建议")
|
||||
dimension: Optional[str] = Field(None, description="所属维度: legal/platform/brand_safety/brief_match")
|
||||
|
||||
# 文本审核字段
|
||||
position: Optional[Position] = Field(None, description="文本位置(脚本审核)")
|
||||
@ -101,6 +102,45 @@ class Violation(BaseModel):
|
||||
source: Optional[ViolationSource] = Field(None, description="违规来源(视频审核)")
|
||||
|
||||
|
||||
# ==================== 多维度审核 ====================
|
||||
|
||||
class ReviewDimension(BaseModel):
|
||||
"""审核维度评分"""
|
||||
score: int = Field(..., ge=0, le=100)
|
||||
passed: bool
|
||||
issue_count: int = 0
|
||||
|
||||
|
||||
class ReviewDimensions(BaseModel):
|
||||
"""四维度审核结果"""
|
||||
legal: ReviewDimension # 法规合规(违禁词、功效词、Brief黑名单词)
|
||||
platform: ReviewDimension # 平台规则
|
||||
brand_safety: ReviewDimension # 品牌安全(竞品、其他品牌词)
|
||||
brief_match: ReviewDimension # Brief 匹配度(卖点覆盖)
|
||||
|
||||
|
||||
class SellingPointMatch(BaseModel):
|
||||
"""卖点匹配结果"""
|
||||
content: str
|
||||
priority: str # "core" | "recommended" | "reference"
|
||||
matched: bool
|
||||
evidence: Optional[str] = None # AI 给出的匹配依据
|
||||
|
||||
|
||||
class BriefMatchDetail(BaseModel):
|
||||
"""Brief 匹配度评分详情"""
|
||||
# 卖点覆盖
|
||||
total_points: int = Field(0, description="需要检查的卖点总数(core + recommended)")
|
||||
matched_points: int = Field(0, description="实际匹配的卖点数")
|
||||
required_points: int = Field(0, description="代理商要求至少体现的卖点条数(min_selling_points)")
|
||||
coverage_score: int = Field(0, ge=0, le=100, description="卖点覆盖率得分")
|
||||
# AI 整体匹配分析
|
||||
overall_score: int = Field(0, ge=0, le=100, description="整体 Brief 匹配度得分")
|
||||
highlights: list[str] = Field(default_factory=list, description="内容亮点(AI 分析)")
|
||||
issues: list[str] = Field(default_factory=list, description="问题点(AI 分析)")
|
||||
explanation: str = Field("", description="评分说明(一句话总结)")
|
||||
|
||||
|
||||
# ==================== 脚本预审 ====================
|
||||
|
||||
class ScriptReviewRequest(BaseModel):
|
||||
@ -108,9 +148,12 @@ class ScriptReviewRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, description="脚本内容")
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
brand_id: str = Field(..., description="品牌 ID")
|
||||
required_points: Optional[list[str]] = Field(None, description="必要卖点列表")
|
||||
selling_points: Optional[list[dict]] = Field(None, description="卖点列表 [{content, priority}]")
|
||||
min_selling_points: Optional[int] = Field(None, ge=0, description="代理商要求至少体现的卖点条数")
|
||||
blacklist_words: Optional[list[dict]] = Field(None, description="Brief 黑名单词 [{word, reason}]")
|
||||
soft_risk_context: Optional[SoftRiskContext] = Field(None, description="软性风控上下文")
|
||||
file_url: Optional[str] = Field(None, description="脚本文件 URL(用于自动解析文本和提取图片)")
|
||||
file_name: Optional[str] = Field(None, description="原始文件名(用于判断格式)")
|
||||
|
||||
|
||||
class ScriptReviewResponse(BaseModel):
|
||||
@ -118,16 +161,22 @@ class ScriptReviewResponse(BaseModel):
|
||||
脚本预审响应
|
||||
|
||||
结构:
|
||||
- score: 合规分数 0-100
|
||||
- score: 加权总分(向后兼容)
|
||||
- summary: 整体摘要
|
||||
- violations: 违规项列表,每项包含 suggestion
|
||||
- missing_points: 遗漏的卖点(可选)
|
||||
- dimensions: 四维度评分(法规/平台/品牌安全/Brief匹配)
|
||||
- selling_point_matches: 卖点匹配详情
|
||||
- violations: 违规项列表,每项带 dimension 标签
|
||||
- missing_points: 遗漏的核心卖点(向后兼容)
|
||||
"""
|
||||
score: int = Field(..., ge=0, le=100, description="合规分数")
|
||||
score: int = Field(..., ge=0, le=100, description="加权总分")
|
||||
summary: str = Field(..., description="审核摘要")
|
||||
dimensions: ReviewDimensions = Field(..., description="四维度评分")
|
||||
selling_point_matches: list[SellingPointMatch] = Field(default_factory=list, description="卖点匹配详情")
|
||||
brief_match_detail: Optional[BriefMatchDetail] = Field(None, description="Brief 匹配度评分详情")
|
||||
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
||||
missing_points: Optional[list[str]] = Field(None, description="遗漏的卖点")
|
||||
missing_points: Optional[list[str]] = Field(None, description="遗漏的核心卖点")
|
||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||
ai_available: bool = Field(True, description="AI 服务是否可用(False 表示降级为纯关键词检测)")
|
||||
|
||||
|
||||
# ==================== 视频审核 ====================
|
||||
|
||||
@ -51,6 +51,9 @@ class OpenAICompatibleClient:
|
||||
timeout: float = 180.0,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
# 自动补全 /v1 后缀(OpenAI SDK 需要完整路径)
|
||||
if not self.base_url.endswith("/v1"):
|
||||
self.base_url = self.base_url + "/v1"
|
||||
self.api_key = api_key
|
||||
self.provider = provider
|
||||
self.timeout = timeout
|
||||
|
||||
@ -53,18 +53,24 @@ class AIServiceFactory:
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
# 解密 API Key
|
||||
api_key = decrypt_api_key(config.api_key_encrypted)
|
||||
|
||||
# 创建客户端
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=config.base_url,
|
||||
api_key=api_key,
|
||||
provider=config.provider,
|
||||
)
|
||||
if config:
|
||||
# 解密 API Key
|
||||
api_key = decrypt_api_key(config.api_key_encrypted)
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=config.base_url,
|
||||
api_key=api_key,
|
||||
provider=config.provider,
|
||||
)
|
||||
else:
|
||||
# 回退到全局 .env 配置
|
||||
from app.config import settings
|
||||
if not settings.AI_API_KEY or not settings.AI_API_BASE_URL:
|
||||
return None
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=settings.AI_API_BASE_URL,
|
||||
api_key=settings.AI_API_KEY,
|
||||
provider=settings.AI_PROVIDER,
|
||||
)
|
||||
|
||||
# 缓存客户端
|
||||
cls._cache[cache_key] = client
|
||||
|
||||
@ -2,12 +2,16 @@
|
||||
文档解析服务
|
||||
从 PDF/Word/Excel 文档中提取纯文本
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DocumentParser:
|
||||
"""从文档中提取纯文本"""
|
||||
@ -38,23 +42,40 @@ class DocumentParser:
|
||||
# 回退:生成预签名 URL 后用 HTTP 下载
|
||||
content = await DocumentParser._download_via_signed_url(document_url)
|
||||
|
||||
# 跳过过大的文件(>20MB),解析可能非常慢且阻塞
|
||||
if len(content) > 20 * 1024 * 1024:
|
||||
logger.warning(f"文件 {document_name} 过大 ({len(content)//1024//1024}MB),已跳过")
|
||||
return ""
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
return DocumentParser.parse_file(tmp_path, document_name)
|
||||
# 文件解析可能很慢(CPU 密集),放到线程池执行
|
||||
return await asyncio.to_thread(DocumentParser.parse_file, tmp_path, document_name)
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
# 图片提取限制
|
||||
MAX_IMAGES = 10
|
||||
MAX_IMAGE_SIZE = 2 * 1024 * 1024 # 2MB per image base64
|
||||
|
||||
@staticmethod
|
||||
async def download_and_get_images(document_url: str, document_name: str) -> Optional[list[str]]:
|
||||
"""
|
||||
下载 PDF 并将页面转为 base64 图片列表(用于图片型 PDF 的 AI 视觉解析)。
|
||||
非 PDF 或非图片型 PDF 返回 None。
|
||||
下载文档并提取嵌入的图片,返回 base64 编码列表。
|
||||
|
||||
支持格式:
|
||||
- PDF: 图片型 PDF 转页面图片
|
||||
- DOCX: 提取 word/media/ 中的嵌入图片
|
||||
- XLSX: 提取 worksheet 中的嵌入图片
|
||||
|
||||
Returns:
|
||||
base64 图片列表,无图片时返回 None
|
||||
"""
|
||||
ext = document_name.rsplit(".", 1)[-1].lower() if "." in document_name else ""
|
||||
if ext != "pdf":
|
||||
if ext not in ("pdf", "doc", "docx", "xls", "xlsx"):
|
||||
return None
|
||||
|
||||
tmp_path: Optional[str] = None
|
||||
@ -63,12 +84,20 @@ class DocumentParser:
|
||||
if file_content is None:
|
||||
file_content = await DocumentParser._download_via_signed_url(document_url)
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
|
||||
tmp.write(file_content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
if DocumentParser.is_image_pdf(tmp_path):
|
||||
return DocumentParser.pdf_to_images_base64(tmp_path)
|
||||
if ext == "pdf":
|
||||
if DocumentParser.is_image_pdf(tmp_path):
|
||||
return DocumentParser.pdf_to_images_base64(tmp_path)
|
||||
return None
|
||||
elif ext in ("doc", "docx"):
|
||||
images = await asyncio.to_thread(DocumentParser._extract_docx_images, tmp_path)
|
||||
return images if images else None
|
||||
elif ext in ("xls", "xlsx"):
|
||||
images = await asyncio.to_thread(DocumentParser._extract_xlsx_images, tmp_path)
|
||||
return images if images else None
|
||||
return None
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
@ -76,32 +105,40 @@ class DocumentParser:
|
||||
|
||||
@staticmethod
|
||||
async def _download_via_tos_sdk(document_url: str) -> Optional[bytes]:
|
||||
"""通过 TOS SDK 直接下载文件(私有桶安全访问)"""
|
||||
try:
|
||||
from app.config import settings
|
||||
from app.services.oss import parse_file_key_from_url
|
||||
import tos as tos_sdk
|
||||
"""通过 TOS SDK 直接下载文件(私有桶安全访问),在线程池中执行避免阻塞"""
|
||||
def _sync_download() -> Optional[bytes]:
|
||||
try:
|
||||
from app.config import settings
|
||||
from app.services.oss import parse_file_key_from_url
|
||||
import tos as tos_sdk
|
||||
|
||||
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||
logger.debug("TOS SDK: AK/SK 未配置,跳过")
|
||||
return None
|
||||
|
||||
file_key = parse_file_key_from_url(document_url)
|
||||
if not file_key or file_key == document_url:
|
||||
logger.debug(f"TOS SDK: 无法从 URL 解析 file_key: {document_url}")
|
||||
return None
|
||||
|
||||
region = settings.TOS_REGION
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
|
||||
client = tos_sdk.TosClientV2(
|
||||
ak=settings.TOS_ACCESS_KEY_ID,
|
||||
sk=settings.TOS_SECRET_ACCESS_KEY,
|
||||
endpoint=f"https://{endpoint}",
|
||||
region=region,
|
||||
)
|
||||
resp = client.get_object(bucket=settings.TOS_BUCKET_NAME, key=file_key)
|
||||
data = resp.read()
|
||||
logger.info(f"TOS SDK: 下载成功, key={file_key}, size={len(data)}")
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"TOS SDK 下载失败,将回退 HTTP: {e}")
|
||||
return None
|
||||
|
||||
file_key = parse_file_key_from_url(document_url)
|
||||
if not file_key or file_key == document_url:
|
||||
return None
|
||||
|
||||
region = settings.TOS_REGION
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
|
||||
client = tos_sdk.TosClientV2(
|
||||
ak=settings.TOS_ACCESS_KEY_ID,
|
||||
sk=settings.TOS_SECRET_ACCESS_KEY,
|
||||
endpoint=f"https://{endpoint}",
|
||||
region=region,
|
||||
)
|
||||
resp = client.get_object(bucket=settings.TOS_BUCKET_NAME, key=file_key)
|
||||
return resp.read()
|
||||
except Exception:
|
||||
return None
|
||||
return await asyncio.to_thread(_sync_download)
|
||||
|
||||
@staticmethod
|
||||
async def _download_via_signed_url(document_url: str) -> bytes:
|
||||
@ -110,10 +147,12 @@ class DocumentParser:
|
||||
|
||||
file_key = parse_file_key_from_url(document_url)
|
||||
signed_url = generate_presigned_url(file_key, expire_seconds=300)
|
||||
logger.info(f"HTTP 签名 URL 下载: key={file_key}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.get(signed_url)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"HTTP 下载成功: {len(resp.content)} bytes")
|
||||
return resp.content
|
||||
|
||||
@staticmethod
|
||||
@ -249,3 +288,62 @@ class DocumentParser:
|
||||
"""纯文本文件"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
@staticmethod
|
||||
def _extract_docx_images(path: str) -> list[str]:
|
||||
"""从 DOCX 文件中提取嵌入图片(DOCX 本质是 ZIP,图片在 word/media/ 目录)"""
|
||||
import zipfile
|
||||
import base64
|
||||
|
||||
images = []
|
||||
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("word/media/"):
|
||||
continue
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext not in image_exts:
|
||||
continue
|
||||
img_data = zf.read(name)
|
||||
b64 = base64.b64encode(img_data).decode()
|
||||
if len(b64) > DocumentParser.MAX_IMAGE_SIZE:
|
||||
logger.debug(f"跳过过大图片: {name} ({len(b64)} bytes)")
|
||||
continue
|
||||
images.append(b64)
|
||||
if len(images) >= DocumentParser.MAX_IMAGES:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"提取 DOCX 图片失败: {e}")
|
||||
|
||||
return images
|
||||
|
||||
@staticmethod
|
||||
def _extract_xlsx_images(path: str) -> list[str]:
|
||||
"""从 XLSX 文件中提取嵌入图片(通过 openpyxl 的 _images 属性)"""
|
||||
import base64
|
||||
|
||||
images = []
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(path, read_only=False)
|
||||
for sheet in wb.worksheets:
|
||||
for img in getattr(sheet, "_images", []):
|
||||
try:
|
||||
img_data = img._data()
|
||||
b64 = base64.b64encode(img_data).decode()
|
||||
if len(b64) > DocumentParser.MAX_IMAGE_SIZE:
|
||||
continue
|
||||
images.append(b64)
|
||||
if len(images) >= DocumentParser.MAX_IMAGES:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if len(images) >= DocumentParser.MAX_IMAGES:
|
||||
break
|
||||
wb.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"提取 XLSX 图片失败: {e}")
|
||||
|
||||
return images
|
||||
|
||||
@ -142,8 +142,6 @@ def get_file_url(file_key: str) -> str:
|
||||
def generate_presigned_url(
|
||||
file_key: str,
|
||||
expire_seconds: int = 3600,
|
||||
download: bool = False,
|
||||
filename: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth)
|
||||
@ -179,21 +177,14 @@ def generate_presigned_url(
|
||||
# 对 file_key 中的路径段分别编码
|
||||
encoded_key = "/".join(quote(seg, safe="") for seg in file_key.split("/"))
|
||||
|
||||
# 查询参数(按字母序排列,TOS V4 签名要求严格字母序)
|
||||
params_dict: dict[str, str] = {
|
||||
"X-Tos-Algorithm": "TOS4-HMAC-SHA256",
|
||||
"X-Tos-Credential": quote(credential, safe=''),
|
||||
"X-Tos-Date": tos_date,
|
||||
"X-Tos-Expires": str(expire_seconds),
|
||||
"X-Tos-SignedHeaders": "host",
|
||||
}
|
||||
if download:
|
||||
dl_name = filename or file_key.split("/")[-1]
|
||||
params_dict["response-content-disposition"] = quote(
|
||||
f'attachment; filename="{dl_name}"', safe=''
|
||||
)
|
||||
|
||||
query_params = "&".join(f"{k}={v}" for k, v in sorted(params_dict.items()))
|
||||
# 查询参数(按字母序排列)
|
||||
query_params = (
|
||||
f"X-Tos-Algorithm=TOS4-HMAC-SHA256"
|
||||
f"&X-Tos-Credential={quote(credential, safe='')}"
|
||||
f"&X-Tos-Date={tos_date}"
|
||||
f"&X-Tos-Expires={expire_seconds}"
|
||||
f"&X-Tos-SignedHeaders=host"
|
||||
)
|
||||
|
||||
# CanonicalRequest
|
||||
canonical_request = (
|
||||
|
||||
@ -459,6 +459,7 @@ async def list_tasks_for_agency(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
stage: Optional[TaskStage] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> Tuple[List[Task], int]:
|
||||
"""获取代理商的任务列表"""
|
||||
query = (
|
||||
@ -473,6 +474,8 @@ async def list_tasks_for_agency(
|
||||
|
||||
if stage:
|
||||
query = query.where(Task.stage == stage)
|
||||
if project_id:
|
||||
query = query.where(Task.project_id == project_id)
|
||||
|
||||
query = query.order_by(Task.created_at.desc())
|
||||
|
||||
@ -480,6 +483,8 @@ async def list_tasks_for_agency(
|
||||
count_query = select(func.count(Task.id)).where(Task.agency_id == agency_id)
|
||||
if stage:
|
||||
count_query = count_query.where(Task.stage == stage)
|
||||
if project_id:
|
||||
count_query = count_query.where(Task.project_id == project_id)
|
||||
count_result = await db.execute(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
@ -497,12 +502,17 @@ async def list_tasks_for_brand(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
stage: Optional[TaskStage] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> Tuple[List[Task], int]:
|
||||
"""获取品牌方的任务列表(通过项目关联)"""
|
||||
# 先获取品牌方的所有项目
|
||||
project_ids_query = select(Project.id).where(Project.brand_id == brand_id)
|
||||
project_ids_result = await db.execute(project_ids_query)
|
||||
project_ids = [row[0] for row in project_ids_result.all()]
|
||||
if project_id:
|
||||
# 指定了项目 ID,直接筛选该项目的任务
|
||||
project_ids = [project_id]
|
||||
else:
|
||||
# 未指定项目,获取品牌方的所有项目
|
||||
project_ids_query = select(Project.id).where(Project.brand_id == brand_id)
|
||||
project_ids_result = await db.execute(project_ids_query)
|
||||
project_ids = [row[0] for row in project_ids_result.all()]
|
||||
|
||||
if not project_ids:
|
||||
return [], 0
|
||||
|
||||
@ -24,6 +24,9 @@ dependencies = [
|
||||
"pdfplumber>=0.10.0",
|
||||
"python-docx>=1.1.0",
|
||||
"openpyxl>=3.1.0",
|
||||
"PyMuPDF>=1.24.0",
|
||||
"tos>=2.7.0",
|
||||
"socksio>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@ -189,9 +189,10 @@ async def seed_data() -> None:
|
||||
id=BRIEF_ID,
|
||||
project_id=PROJECT_ID,
|
||||
selling_points=[
|
||||
{"content": "SPF50+ PA++++,超强防晒", "required": True},
|
||||
{"content": "轻薄不油腻,适合日常通勤", "required": True},
|
||||
{"content": "添加玻尿酸成分,防晒同时保湿", "required": False},
|
||||
{"content": "SPF50+ PA++++,超强防晒", "priority": "core"},
|
||||
{"content": "轻薄不油腻,适合日常通勤", "priority": "core"},
|
||||
{"content": "添加玻尿酸成分,防晒同时保湿", "priority": "recommended"},
|
||||
{"content": "获得皮肤科医生推荐", "priority": "reference"},
|
||||
],
|
||||
blacklist_words=[
|
||||
{"word": "最好", "reason": "绝对化用语"},
|
||||
@ -200,6 +201,7 @@ async def seed_data() -> None:
|
||||
],
|
||||
competitors=["安耐晒", "怡思丁", "薇诺娜"],
|
||||
brand_tone="年轻、活力、专业、可信赖",
|
||||
min_selling_points=2,
|
||||
min_duration=30,
|
||||
max_duration=60,
|
||||
other_requirements="请在视频中展示产品实际使用效果,包含户外场景拍摄",
|
||||
@ -236,8 +238,38 @@ async def seed_data() -> None:
|
||||
script_ai_result={
|
||||
"score": 85,
|
||||
"summary": "脚本整体符合要求,卖点覆盖充分",
|
||||
"issues": [
|
||||
{"type": "soft_warning", "content": "建议增加产品成分说明"},
|
||||
"dimensions": {
|
||||
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"platform": {"score": 85, "passed": True, "issue_count": 1},
|
||||
"brand_safety": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brief_match": {"score": 80, "passed": True, "issue_count": 1},
|
||||
},
|
||||
"selling_point_matches": [
|
||||
{"content": "SPF50+ PA++++,超强防晒", "priority": "core", "matched": True, "evidence": "脚本中提到了SPF50+防晒参数"},
|
||||
{"content": "轻薄不油腻,适合日常通勤", "priority": "core", "matched": True, "evidence": "提到了轻薄质地不油腻"},
|
||||
{"content": "添加玻尿酸成分,防晒同时保湿", "priority": "recommended", "matched": False, "evidence": "未提及玻尿酸成分"},
|
||||
],
|
||||
"brief_match_detail": {
|
||||
"total_points": 3,
|
||||
"matched_points": 2,
|
||||
"required_points": 2,
|
||||
"coverage_score": 100,
|
||||
"overall_score": 75,
|
||||
"highlights": [
|
||||
"防晒参数描述准确,SPF50+ PA++++完整提及",
|
||||
"产品使用场景贴合Brief要求的日常通勤场景",
|
||||
],
|
||||
"issues": [
|
||||
"缺少玻尿酸保湿成分的说明,建议补充产品成分亮点",
|
||||
"脚本中使用了\"神器\"等夸张用语,需替换为更客观的表述",
|
||||
],
|
||||
"explanation": "脚本覆盖了2/2条要求卖点,核心卖点全部匹配。整体内容方向正确,但部分细节可优化。",
|
||||
},
|
||||
"violations": [
|
||||
{"type": "forbidden_word", "content": "神器", "severity": "medium", "suggestion": "建议替换为\"好物\"", "dimension": "platform"},
|
||||
],
|
||||
"soft_warnings": [
|
||||
{"type": "suggestion", "content": "建议增加产品成分说明", "suggestion": "可提及玻尿酸等核心成分"},
|
||||
],
|
||||
},
|
||||
script_ai_reviewed_at=NOW - timedelta(hours=1),
|
||||
@ -258,7 +290,33 @@ async def seed_data() -> None:
|
||||
script_ai_result={
|
||||
"score": 92,
|
||||
"summary": "脚本质量优秀,完全符合 Brief 要求",
|
||||
"issues": [],
|
||||
"dimensions": {
|
||||
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"platform": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brand_safety": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brief_match": {"score": 90, "passed": True, "issue_count": 0},
|
||||
},
|
||||
"selling_point_matches": [
|
||||
{"content": "SPF50+ PA++++,超强防晒", "priority": "core", "matched": True, "evidence": "脚本完整提及防晒参数"},
|
||||
{"content": "轻薄不油腻,适合日常通勤", "priority": "core", "matched": True, "evidence": "详细描述了质地体验"},
|
||||
{"content": "添加玻尿酸成分,防晒同时保湿", "priority": "recommended", "matched": True, "evidence": "提及了玻尿酸保湿功能"},
|
||||
],
|
||||
"brief_match_detail": {
|
||||
"total_points": 3,
|
||||
"matched_points": 3,
|
||||
"required_points": 2,
|
||||
"coverage_score": 100,
|
||||
"overall_score": 90,
|
||||
"highlights": [
|
||||
"所有核心和推荐卖点均完整覆盖",
|
||||
"产品使用场景自然,与Brief要求高度一致",
|
||||
"成分说明准确,玻尿酸保湿功能表述清晰",
|
||||
],
|
||||
"issues": [],
|
||||
"explanation": "脚本覆盖了3/2条要求卖点(超出要求),与Brief整体匹配度优秀。",
|
||||
},
|
||||
"violations": [],
|
||||
"soft_warnings": [],
|
||||
},
|
||||
script_ai_reviewed_at=NOW - timedelta(days=2),
|
||||
script_agency_status=TaskStatus.PASSED,
|
||||
@ -283,7 +341,19 @@ async def seed_data() -> None:
|
||||
script_file_name="防晒霜种草脚本v4.pdf",
|
||||
script_uploaded_at=NOW - timedelta(days=7),
|
||||
script_ai_score=90,
|
||||
script_ai_result={"score": 90, "summary": "符合要求", "issues": []},
|
||||
script_ai_result={
|
||||
"score": 90,
|
||||
"summary": "符合要求",
|
||||
"dimensions": {
|
||||
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"platform": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brand_safety": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brief_match": {"score": 85, "passed": True, "issue_count": 0},
|
||||
},
|
||||
"selling_point_matches": [],
|
||||
"violations": [],
|
||||
"soft_warnings": [],
|
||||
},
|
||||
script_ai_reviewed_at=NOW - timedelta(days=7),
|
||||
script_agency_status=TaskStatus.PASSED,
|
||||
script_agency_comment="通过",
|
||||
@ -298,7 +368,19 @@ async def seed_data() -> None:
|
||||
video_duration=45,
|
||||
video_uploaded_at=NOW - timedelta(days=5),
|
||||
video_ai_score=88,
|
||||
video_ai_result={"score": 88, "summary": "视频质量良好", "issues": []},
|
||||
video_ai_result={
|
||||
"score": 88,
|
||||
"summary": "视频质量良好",
|
||||
"dimensions": {
|
||||
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"platform": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brand_safety": {"score": 85, "passed": True, "issue_count": 0},
|
||||
"brief_match": {"score": 80, "passed": True, "issue_count": 0},
|
||||
},
|
||||
"selling_point_matches": [],
|
||||
"violations": [],
|
||||
"soft_warnings": [],
|
||||
},
|
||||
video_ai_reviewed_at=NOW - timedelta(days=5),
|
||||
video_agency_status=TaskStatus.PASSED,
|
||||
video_agency_comment="视频效果好",
|
||||
|
||||
@ -215,7 +215,11 @@ class TestSellingPointCheck:
|
||||
"content": "这个产品很好用",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"required_points": ["功效说明", "使用方法", "品牌名称"],
|
||||
"selling_points": [
|
||||
{"content": "功效说明", "priority": "core"},
|
||||
{"content": "使用方法", "priority": "core"},
|
||||
{"content": "品牌名称", "priority": "recommended"},
|
||||
],
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
@ -223,6 +227,9 @@ class TestSellingPointCheck:
|
||||
|
||||
assert parsed.missing_points is not None
|
||||
assert isinstance(parsed.missing_points, list)
|
||||
# 验证多维度评分存在
|
||||
assert parsed.dimensions is not None
|
||||
assert parsed.dimensions.brief_match is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_points_covered(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
@ -234,7 +241,11 @@ class TestSellingPointCheck:
|
||||
"content": "品牌A的护肤精华,每天早晚各用一次,可以让肌肤更水润",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"required_points": ["品牌名称", "使用方法", "功效说明"],
|
||||
"selling_points": [
|
||||
{"content": "护肤精华", "priority": "core"},
|
||||
{"content": "早晚各用一次", "priority": "core"},
|
||||
{"content": "肌肤更水润", "priority": "recommended"},
|
||||
],
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
@ -30,12 +30,14 @@ import {
|
||||
Loader2,
|
||||
Search,
|
||||
AlertCircle,
|
||||
RotateCcw
|
||||
RotateCcw,
|
||||
Users,
|
||||
UserPlus
|
||||
} from 'lucide-react'
|
||||
import { getPlatformInfo } from '@/lib/platforms'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
|
||||
import type { RuleConflict } from '@/types/rules'
|
||||
import type { RuleConflict, ParsedRulesData } from '@/types/rules'
|
||||
|
||||
// 单个文件上传状态
|
||||
interface UploadingFileItem {
|
||||
@ -49,6 +51,9 @@ interface UploadingFileItem {
|
||||
}
|
||||
import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
|
||||
import type { ProjectResponse } from '@/types/project'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
import type { CreatorDetail } from '@/types/organization'
|
||||
import { mapTaskToUI } from '@/lib/taskStageMapper'
|
||||
|
||||
// 文件类型
|
||||
type BriefFile = {
|
||||
@ -121,11 +126,11 @@ const mockAgencyConfig = {
|
||||
},
|
||||
// 代理商配置的卖点(可编辑)
|
||||
sellingPoints: [
|
||||
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
|
||||
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
|
||||
{ id: 'sp3', content: '延展性好,易推开', required: false },
|
||||
{ id: 'sp4', content: '适合敏感肌', required: false },
|
||||
{ id: 'sp5', content: '夏日必备防晒', required: true },
|
||||
{ id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const },
|
||||
{ id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const },
|
||||
{ id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const },
|
||||
{ id: 'sp4', content: '适合敏感肌', priority: 'reference' as const },
|
||||
{ id: 'sp5', content: '夏日必备防晒', priority: 'core' as const },
|
||||
],
|
||||
// 代理商配置的违禁词(可编辑)
|
||||
blacklistWords: [
|
||||
@ -136,32 +141,36 @@ const mockAgencyConfig = {
|
||||
],
|
||||
}
|
||||
|
||||
// 平台规则
|
||||
const platformRules = {
|
||||
douyin: {
|
||||
name: '抖音',
|
||||
rules: [
|
||||
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致', '绝对', '永久', '万能', '特效'] },
|
||||
{ category: '医疗相关禁用', items: ['治疗', '药用', '医学', '临床', '处方'] },
|
||||
{ category: '虚假宣传', items: ['100%', '纯天然', '无副作用', '立竿见影'] },
|
||||
],
|
||||
},
|
||||
xiaohongshu: {
|
||||
name: '小红书',
|
||||
rules: [
|
||||
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极品', '绝对'] },
|
||||
{ category: '功效承诺禁用', items: ['包治', '根治', '祛除', '永久'] },
|
||||
],
|
||||
},
|
||||
bilibili: {
|
||||
name: 'B站',
|
||||
rules: [
|
||||
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致'] },
|
||||
{ category: '虚假宣传', items: ['100%', '纯天然', '无副作用'] },
|
||||
],
|
||||
},
|
||||
// 平台规则类型
|
||||
interface PlatformRuleCategory {
|
||||
category: string
|
||||
items: string[]
|
||||
}
|
||||
|
||||
// 将后端 ParsedRulesData 转为 UI 展示格式
|
||||
function parsedRulesToCategories(parsed: ParsedRulesData): PlatformRuleCategory[] {
|
||||
const categories: PlatformRuleCategory[] = []
|
||||
if (parsed.forbidden_words?.length) {
|
||||
categories.push({ category: '违禁词', items: parsed.forbidden_words })
|
||||
}
|
||||
if (parsed.restricted_words?.length) {
|
||||
categories.push({ category: '限制用语', items: parsed.restricted_words.map(w => w.word) })
|
||||
}
|
||||
if (parsed.content_requirements?.length) {
|
||||
categories.push({ category: '内容要求', items: parsed.content_requirements })
|
||||
}
|
||||
if (parsed.other_rules?.length) {
|
||||
categories.push({ category: '其他规则', items: parsed.other_rules.map(r => r.rule) })
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
// Mock 模式下的默认平台规则
|
||||
const mockPlatformRules: PlatformRuleCategory[] = [
|
||||
{ category: '广告法违禁词', items: ['最', '第一', '顶级', '极致', '绝对', '永久', '万能', '特效'] },
|
||||
{ category: '功效承诺禁用', items: ['包治', '根治', '祛除', '永久'] },
|
||||
]
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@ -225,6 +234,7 @@ export default function BriefConfigPage() {
|
||||
const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig)
|
||||
const [newSellingPoint, setNewSellingPoint] = useState('')
|
||||
const [newBlacklistWord, setNewBlacklistWord] = useState('')
|
||||
const [minSellingPoints, setMinSellingPoints] = useState<number | null>(null)
|
||||
|
||||
// 弹窗状态
|
||||
const [showFilesModal, setShowFilesModal] = useState(false)
|
||||
@ -236,6 +246,16 @@ export default function BriefConfigPage() {
|
||||
const [isAIParsing, setIsAIParsing] = useState(false)
|
||||
const isUploading = uploadingFiles.some(f => f.status === 'uploading')
|
||||
|
||||
// 动态平台规则
|
||||
const [dynamicPlatformRules, setDynamicPlatformRules] = useState<PlatformRuleCategory[]>(mockPlatformRules)
|
||||
const [platformRuleName, setPlatformRuleName] = useState('')
|
||||
|
||||
// 任务管理
|
||||
const [projectTasks, setProjectTasks] = useState<TaskResponse[]>([])
|
||||
const [availableCreators, setAvailableCreators] = useState<CreatorDetail[]>([])
|
||||
const [showCreatorModal, setShowCreatorModal] = useState(false)
|
||||
const [creatingTask, setCreatingTask] = useState(false)
|
||||
|
||||
// 规则冲突检测
|
||||
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
|
||||
const [showConflictModal, setShowConflictModal] = useState(false)
|
||||
@ -263,6 +283,38 @@ export default function BriefConfigPage() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showPlatformSelect])
|
||||
|
||||
const handleCreateTask = async (creatorId: string) => {
|
||||
setCreatingTask(true)
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
const creator = availableCreators.find(c => c.id === creatorId)
|
||||
const seq = projectTasks.length + 1
|
||||
setProjectTasks(prev => [...prev, {
|
||||
id: `TK-mock-${Date.now()}`, name: `${brandBrief.projectName} #${seq}`, sequence: seq,
|
||||
stage: 'script_upload',
|
||||
project: { id: projectId, name: brandBrief.projectName },
|
||||
agency: { id: 'AG000001', name: '星辰传媒' },
|
||||
creator: { id: creatorId, name: creator?.name || '未知达人' },
|
||||
appeal_count: 0, is_appeal: false,
|
||||
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
||||
}])
|
||||
toast.success('任务创建成功')
|
||||
setShowCreatorModal(false)
|
||||
} else {
|
||||
await api.createTask({ project_id: projectId, creator_id: creatorId })
|
||||
const tasksResp = await api.listTasks(1, 100, undefined, projectId)
|
||||
setProjectTasks(tasksResp.items)
|
||||
toast.success('任务创建成功')
|
||||
setShowCreatorModal(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error('创建任务失败')
|
||||
} finally {
|
||||
setCreatingTask(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckConflicts = async (platform: string) => {
|
||||
setShowPlatformSelect(false)
|
||||
setIsCheckingConflicts(true)
|
||||
@ -315,6 +367,29 @@ export default function BriefConfigPage() {
|
||||
const loadData = useCallback(async () => {
|
||||
if (USE_MOCK) {
|
||||
// Mock 模式使用默认数据
|
||||
setProjectTasks([
|
||||
{
|
||||
id: 'TK000001', name: 'XX品牌618推广 #1', sequence: 1, stage: 'script_upload',
|
||||
project: { id: 'proj-001', name: 'XX品牌618推广' },
|
||||
agency: { id: 'AG000001', name: '星辰传媒' },
|
||||
creator: { id: 'CR000001', name: '李小红' },
|
||||
appeal_count: 0, is_appeal: false,
|
||||
created_at: '2026-02-01T10:00:00', updated_at: '2026-02-01T10:00:00',
|
||||
},
|
||||
{
|
||||
id: 'TK000002', name: 'XX品牌618推广 #2', sequence: 2, stage: 'script_agency_review',
|
||||
project: { id: 'proj-001', name: 'XX品牌618推广' },
|
||||
agency: { id: 'AG000001', name: '星辰传媒' },
|
||||
creator: { id: 'CR000002', name: '张大力' },
|
||||
appeal_count: 0, is_appeal: false,
|
||||
created_at: '2026-02-02T10:00:00', updated_at: '2026-02-03T10:00:00',
|
||||
},
|
||||
])
|
||||
setAvailableCreators([
|
||||
{ id: 'CR000001', name: '李小红', douyin_account: 'lixiaohong', xiaohongshu_account: null, bilibili_account: null },
|
||||
{ id: 'CR000002', name: '张大力', douyin_account: 'zhangdali', xiaohongshu_account: 'zhangdali_xhs', bilibili_account: null },
|
||||
{ id: 'CR000003', name: '王美丽', douyin_account: null, xiaohongshu_account: 'wangmeili', bilibili_account: null },
|
||||
])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@ -367,6 +442,11 @@ export default function BriefConfigPage() {
|
||||
// 映射到代理商配置视图
|
||||
const hasBrief = !!(brief?.selling_points?.length || brief?.blacklist_words?.length || brief?.brand_tone)
|
||||
|
||||
// 加载最少卖点数配置
|
||||
if (brief?.min_selling_points != null) {
|
||||
setMinSellingPoints(brief.min_selling_points)
|
||||
}
|
||||
|
||||
setAgencyConfig({
|
||||
status: hasBrief ? 'configured' : 'pending',
|
||||
configuredAt: hasBrief ? (brief!.updated_at.split('T')[0]) : '',
|
||||
@ -377,17 +457,25 @@ export default function BriefConfigPage() {
|
||||
uploadedAt: brief!.updated_at?.split('T')[0] || '',
|
||||
url: att.url,
|
||||
})),
|
||||
aiParsedContent: {
|
||||
productName: brief?.brand_tone || '待解析',
|
||||
targetAudience: '待解析',
|
||||
contentRequirements: brief?.min_duration && brief?.max_duration
|
||||
? `视频时长 ${brief.min_duration}-${brief.max_duration} 秒`
|
||||
: (brief?.other_requirements || '待解析'),
|
||||
},
|
||||
aiParsedContent: (() => {
|
||||
// brand_tone 存储格式: "产品名称\n目标受众"
|
||||
const toneParts = (brief?.brand_tone || '').split('\n')
|
||||
const productName = toneParts[0] || ''
|
||||
const targetAudience = toneParts[1] || ''
|
||||
const contentRequirements = brief?.other_requirements || ''
|
||||
return {
|
||||
productName: productName || '待解析',
|
||||
targetAudience: targetAudience || '待解析',
|
||||
contentRequirements: contentRequirements
|
||||
|| (brief?.min_duration && brief?.max_duration
|
||||
? `视频时长 ${brief.min_duration}-${brief.max_duration} 秒`
|
||||
: '待解析'),
|
||||
}
|
||||
})(),
|
||||
sellingPoints: (brief?.selling_points || []).map((sp, i) => ({
|
||||
id: `sp-${i}`,
|
||||
content: sp.content,
|
||||
required: sp.required,
|
||||
priority: (sp.priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference',
|
||||
})),
|
||||
blacklistWords: (brief?.blacklist_words || []).map((bw, i) => ({
|
||||
id: `bw-${i}`,
|
||||
@ -395,6 +483,38 @@ export default function BriefConfigPage() {
|
||||
reason: bw.reason,
|
||||
})),
|
||||
})
|
||||
|
||||
// 3. 获取平台规则
|
||||
const platformKey = project.platform || 'douyin'
|
||||
const platformInfo = getPlatformInfo(platformKey)
|
||||
setPlatformRuleName(platformInfo?.name || platformKey)
|
||||
try {
|
||||
const rulesResp = await api.listBrandPlatformRules({ platform: platformKey, status: 'active' })
|
||||
if (rulesResp.items.length > 0) {
|
||||
const categories = parsedRulesToCategories(rulesResp.items[0].parsed_rules)
|
||||
if (categories.length > 0) {
|
||||
setDynamicPlatformRules(categories)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('获取平台规则失败,使用默认规则:', e)
|
||||
}
|
||||
|
||||
// 4. 获取项目任务列表
|
||||
try {
|
||||
const tasksResp = await api.listTasks(1, 100, undefined, projectId)
|
||||
setProjectTasks(tasksResp.items)
|
||||
} catch (e) {
|
||||
console.warn('获取项目任务列表失败:', e)
|
||||
}
|
||||
|
||||
// 5. 获取可选达人列表
|
||||
try {
|
||||
const creatorsResp = await api.listAgencyCreators()
|
||||
setAvailableCreators(creatorsResp.items)
|
||||
} catch (e) {
|
||||
console.warn('获取达人列表失败:', e)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载 Brief 详情失败:', err)
|
||||
toast.error('加载 Brief 详情失败')
|
||||
@ -408,7 +528,6 @@ export default function BriefConfigPage() {
|
||||
}, [loadData])
|
||||
|
||||
const platform = getPlatformInfo(brandBrief.platform)
|
||||
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
|
||||
|
||||
// 下载文件
|
||||
const handleDownload = async (file: BriefFile) => {
|
||||
@ -417,17 +536,7 @@ export default function BriefConfigPage() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const signedUrl = await api.getSignedUrl(file.url)
|
||||
const resp = await fetch(signedUrl)
|
||||
const blob = await resp.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = file.name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
await api.downloadFile(file.url, file.name)
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
}
|
||||
@ -442,8 +551,8 @@ export default function BriefConfigPage() {
|
||||
if (!USE_MOCK && file.url) {
|
||||
setPreviewLoading(true)
|
||||
try {
|
||||
const signedUrl = await api.getSignedUrl(file.url)
|
||||
setPreviewUrl(signedUrl)
|
||||
const blobUrl = await api.getPreviewUrl(file.url)
|
||||
setPreviewUrl(blobUrl)
|
||||
} catch {
|
||||
toast.error('获取预览链接失败')
|
||||
} finally {
|
||||
@ -463,9 +572,68 @@ export default function BriefConfigPage() {
|
||||
// AI 解析
|
||||
const handleAIParse = async () => {
|
||||
setIsAIParsing(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
setIsAIParsing(false)
|
||||
toast.success('AI 解析完成!')
|
||||
|
||||
if (USE_MOCK) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
setIsAIParsing(false)
|
||||
toast.success('AI 解析完成!')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.parseBrief(projectId)
|
||||
|
||||
// 更新 AI 解析结果
|
||||
setAgencyConfig(prev => ({
|
||||
...prev,
|
||||
aiParsedContent: {
|
||||
productName: result.product_name || prev.aiParsedContent.productName,
|
||||
targetAudience: result.target_audience || prev.aiParsedContent.targetAudience,
|
||||
contentRequirements: result.content_requirements || prev.aiParsedContent.contentRequirements,
|
||||
},
|
||||
// 如果 AI 解析出了卖点且当前没有卖点,则自动填充
|
||||
sellingPoints: result.selling_points?.length
|
||||
? result.selling_points.map((sp, i) => ({
|
||||
id: `sp-ai-${i}`,
|
||||
content: sp.content,
|
||||
priority: ((sp as any).priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference',
|
||||
}))
|
||||
: prev.sellingPoints,
|
||||
// 如果 AI 解析出了违禁词且当前没有违禁词,则自动填充
|
||||
blacklistWords: result.blacklist_words?.length
|
||||
? result.blacklist_words.map((bw, i) => ({
|
||||
id: `bw-ai-${i}`,
|
||||
word: bw.word,
|
||||
reason: bw.reason,
|
||||
}))
|
||||
: prev.blacklistWords,
|
||||
}))
|
||||
|
||||
// AI 解析成功后自动保存到后端
|
||||
if (!USE_MOCK) {
|
||||
try {
|
||||
await api.updateBriefByAgency(projectId, {
|
||||
brand_tone: [result.product_name, result.target_audience].filter(Boolean).join('\n'),
|
||||
other_requirements: result.content_requirements || undefined,
|
||||
selling_points: result.selling_points?.length
|
||||
? result.selling_points.map(sp => ({ content: sp.content, priority: (sp as any).priority || (sp.required ? 'core' : 'recommended') })) as any
|
||||
: undefined,
|
||||
blacklist_words: result.blacklist_words?.length
|
||||
? result.blacklist_words.map(bw => ({ word: bw.word, reason: bw.reason }))
|
||||
: undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('保存 AI 解析结果失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('AI 解析完成!')
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || 'AI 解析失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setIsAIParsing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
@ -474,32 +642,24 @@ export default function BriefConfigPage() {
|
||||
|
||||
if (!USE_MOCK) {
|
||||
try {
|
||||
const payload = {
|
||||
// 代理商通过专用 PATCH 端点保存
|
||||
await api.updateBriefByAgency(projectId, {
|
||||
selling_points: agencyConfig.sellingPoints.map(sp => ({
|
||||
content: sp.content,
|
||||
required: sp.required,
|
||||
})),
|
||||
priority: sp.priority,
|
||||
})) as any,
|
||||
blacklist_words: agencyConfig.blacklistWords.map(bw => ({
|
||||
word: bw.word,
|
||||
reason: bw.reason,
|
||||
})),
|
||||
competitors: brandBrief.brandRules.competitors,
|
||||
brand_tone: agencyConfig.aiParsedContent.productName,
|
||||
other_requirements: brandBrief.brandRules.restrictions,
|
||||
agency_attachments: agencyConfig.agencyFiles.map(f => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
url: f.url || '',
|
||||
size: f.size,
|
||||
})),
|
||||
}
|
||||
|
||||
// 尝试更新,如果 Brief 不存在则创建
|
||||
try {
|
||||
await api.updateBrief(projectId, payload)
|
||||
} catch {
|
||||
await api.createBrief(projectId, payload)
|
||||
}
|
||||
min_selling_points: minSellingPoints,
|
||||
})
|
||||
|
||||
setIsSaving(false)
|
||||
toast.success('配置已保存!')
|
||||
@ -523,7 +683,7 @@ export default function BriefConfigPage() {
|
||||
if (!newSellingPoint.trim()) return
|
||||
setAgencyConfig(prev => ({
|
||||
...prev,
|
||||
sellingPoints: [...prev.sellingPoints, { id: `sp${Date.now()}`, content: newSellingPoint, required: false }]
|
||||
sellingPoints: [...prev.sellingPoints, { id: `sp${Date.now()}`, content: newSellingPoint, priority: 'recommended' as const }]
|
||||
}))
|
||||
setNewSellingPoint('')
|
||||
}
|
||||
@ -535,12 +695,15 @@ export default function BriefConfigPage() {
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleRequired = (id: string) => {
|
||||
const cyclePriority = (id: string) => {
|
||||
const order: Array<'core' | 'recommended' | 'reference'> = ['core', 'recommended', 'reference']
|
||||
setAgencyConfig(prev => ({
|
||||
...prev,
|
||||
sellingPoints: prev.sellingPoints.map(sp =>
|
||||
sp.id === id ? { ...sp, required: !sp.required } : sp
|
||||
)
|
||||
sellingPoints: prev.sellingPoints.map(sp => {
|
||||
if (sp.id !== id) return sp
|
||||
const idx = order.indexOf(sp.priority)
|
||||
return { ...sp, priority: order[(idx + 1) % order.length] }
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
@ -561,6 +724,20 @@ export default function BriefConfigPage() {
|
||||
}))
|
||||
}
|
||||
|
||||
// 自动保存代理商附件到后端(防止刷新丢失)
|
||||
const autoSaveAgencyFiles = useCallback(async (files: AgencyFile[]) => {
|
||||
if (USE_MOCK) return
|
||||
try {
|
||||
await api.updateBriefByAgency(projectId, {
|
||||
agency_attachments: files.map(f => ({
|
||||
id: f.id, name: f.name, url: f.url || '', size: f.size,
|
||||
})),
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('自动保存代理商附件失败:', e)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
// 上传单个代理商文件
|
||||
const uploadSingleAgencyFile = async (file: File, fileId: string) => {
|
||||
if (USE_MOCK) {
|
||||
@ -589,7 +766,12 @@ export default function BriefConfigPage() {
|
||||
id: fileId, name: file.name, size: formatFileSize(file.size),
|
||||
uploadedAt: new Date().toISOString().split('T')[0], url: result.url,
|
||||
}
|
||||
setAgencyConfig(prev => ({ ...prev, agencyFiles: [...prev.agencyFiles, newFile] }))
|
||||
setAgencyConfig(prev => {
|
||||
const updated = [...prev.agencyFiles, newFile]
|
||||
// 文件上传成功后自动保存到后端
|
||||
autoSaveAgencyFiles(updated)
|
||||
return { ...prev, agencyFiles: updated }
|
||||
})
|
||||
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '上传失败'
|
||||
@ -639,10 +821,12 @@ export default function BriefConfigPage() {
|
||||
}
|
||||
|
||||
const removeAgencyFile = (id: string) => {
|
||||
setAgencyConfig(prev => ({
|
||||
...prev,
|
||||
agencyFiles: prev.agencyFiles.filter(f => f.id !== id)
|
||||
}))
|
||||
setAgencyConfig(prev => {
|
||||
const updated = prev.agencyFiles.filter(f => f.id !== id)
|
||||
// 删除后也自动保存
|
||||
autoSaveAgencyFiles(updated)
|
||||
return { ...prev, agencyFiles: updated }
|
||||
})
|
||||
}
|
||||
|
||||
const [previewAgencyUrl, setPreviewAgencyUrl] = useState<string | null>(null)
|
||||
@ -653,8 +837,8 @@ export default function BriefConfigPage() {
|
||||
if (!USE_MOCK && file.url) {
|
||||
setPreviewAgencyLoading(true)
|
||||
try {
|
||||
const signedUrl = await api.getSignedUrl(file.url)
|
||||
setPreviewAgencyUrl(signedUrl)
|
||||
const blobUrl = await api.getPreviewUrl(file.url)
|
||||
setPreviewAgencyUrl(blobUrl)
|
||||
} catch {
|
||||
toast.error('获取预览链接失败')
|
||||
} finally {
|
||||
@ -669,17 +853,7 @@ export default function BriefConfigPage() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const signedUrl = await api.getSignedUrl(file.url)
|
||||
const resp = await fetch(signedUrl)
|
||||
const blob = await resp.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = file.name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
await api.downloadFile(file.url, file.name)
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
}
|
||||
@ -855,6 +1029,126 @@ export default function BriefConfigPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ===== 任务管理区块 ===== */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users size={18} className="text-accent-green" />
|
||||
项目任务
|
||||
<span className="text-sm font-normal text-text-secondary">
|
||||
{projectTasks.length} 个任务
|
||||
</span>
|
||||
</CardTitle>
|
||||
<Button size="sm" onClick={() => setShowCreatorModal(true)}>
|
||||
<UserPlus size={14} />
|
||||
分配达人
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{projectTasks.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{projectTasks.map((task) => {
|
||||
const uiState = mapTaskToUI(task)
|
||||
return (
|
||||
<div key={task.id} className="flex items-center justify-between p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-accent-indigo/15 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-accent-indigo">
|
||||
{task.creator.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-text-primary text-sm">{task.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-text-secondary">
|
||||
<span>达人: {task.creator.name}</span>
|
||||
<span>创建: {task.created_at.split('T')[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2.5 py-1 text-xs font-medium rounded-lg ${
|
||||
uiState.statusLabel === '已完成' ? 'bg-accent-green/15 text-accent-green' :
|
||||
uiState.statusLabel === '已驳回' ? 'bg-accent-coral/15 text-accent-coral' :
|
||||
uiState.statusLabel === '待上传' ? 'bg-yellow-500/15 text-yellow-400' :
|
||||
'bg-accent-indigo/15 text-accent-indigo'
|
||||
}`}>
|
||||
{uiState.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<Users size={40} className="mx-auto text-text-tertiary mb-3" />
|
||||
<p className="text-text-secondary">暂无任务</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">点击「分配达人」创建任务</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 达人选择弹窗 */}
|
||||
<Modal
|
||||
isOpen={showCreatorModal}
|
||||
onClose={() => setShowCreatorModal(false)}
|
||||
title="选择达人"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
选择一位达人为其创建任务。同一达人可多次选择(用于拍摄多个视频)。
|
||||
</p>
|
||||
{availableCreators.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{availableCreators.map((creator) => {
|
||||
const taskCount = projectTasks.filter(t => t.creator.id === creator.id).length
|
||||
return (
|
||||
<button
|
||||
key={creator.id}
|
||||
type="button"
|
||||
onClick={() => handleCreateTask(creator.id)}
|
||||
disabled={creatingTask}
|
||||
className="w-full flex items-center justify-between p-4 bg-bg-elevated rounded-lg hover:bg-bg-elevated/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-accent-indigo/15 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-accent-indigo">
|
||||
{creator.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-text-primary text-sm">{creator.name}</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{[creator.douyin_account && '抖音', creator.xiaohongshu_account && '小红书', creator.bilibili_account && 'B站'].filter(Boolean).join(' · ') || '暂无平台账号'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{taskCount > 0 && (
|
||||
<span className="text-xs text-text-tertiary">已有 {taskCount} 个任务</span>
|
||||
)}
|
||||
{creatingTask ? (
|
||||
<Loader2 size={16} className="animate-spin text-accent-indigo" />
|
||||
) : (
|
||||
<Plus size={16} className="text-text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<Users size={40} className="mx-auto text-text-tertiary mb-3" />
|
||||
<p className="text-text-secondary">暂无可选达人</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">请先在「达人管理」中添加达人</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ===== 第二部分:代理商配置(可编辑)===== */}
|
||||
<div className="p-4 bg-accent-indigo/10 rounded-lg border border-accent-indigo/30">
|
||||
<div className="flex items-start gap-3">
|
||||
@ -1035,12 +1329,14 @@ export default function BriefConfigPage() {
|
||||
<div key={sp.id} className="flex items-center gap-3 p-3 bg-bg-elevated rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleRequired(sp.id)}
|
||||
onClick={() => cyclePriority(sp.id)}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
sp.required ? 'bg-accent-coral/20 text-accent-coral' : 'bg-bg-page text-text-tertiary'
|
||||
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||
'bg-bg-page text-text-tertiary'
|
||||
}`}
|
||||
>
|
||||
{sp.required ? '必选' : '可选'}
|
||||
{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}
|
||||
</button>
|
||||
<span className="flex-1 text-text-primary">{sp.content}</span>
|
||||
<button
|
||||
@ -1066,6 +1362,48 @@ export default function BriefConfigPage() {
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{/* 最少卖点数配置 */}
|
||||
<div className="p-3 bg-bg-elevated rounded-lg border border-border-subtle">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">最少体现卖点数</p>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">
|
||||
AI 审核时按此数量计算覆盖率评分,不设置则默认要求覆盖全部核心+推荐卖点
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinSellingPoints(prev => prev === null ? Math.max(1, agencyConfig.sellingPoints.filter(sp => sp.priority !== 'reference').length) : prev > 1 ? prev - 1 : prev)}
|
||||
className="w-8 h-8 rounded-lg bg-bg-page border border-border-subtle flex items-center justify-center hover:bg-bg-card transition-colors text-text-secondary"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-10 text-center text-sm font-medium text-text-primary">
|
||||
{minSellingPoints ?? '-'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const max = agencyConfig.sellingPoints.filter(sp => sp.priority !== 'reference').length
|
||||
setMinSellingPoints(prev => prev === null ? 1 : prev < max ? prev + 1 : prev)
|
||||
}}
|
||||
className="w-8 h-8 rounded-lg bg-bg-page border border-border-subtle flex items-center justify-center hover:bg-bg-card transition-colors text-text-secondary"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{minSellingPoints !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinSellingPoints(null)}
|
||||
className="text-xs text-text-tertiary hover:text-text-secondary ml-1"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -1075,7 +1413,7 @@ export default function BriefConfigPage() {
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-accent-amber" />
|
||||
{rules.name}平台规则
|
||||
{platformRuleName || platform?.name || ''}平台规则
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onClick={handleExportRules} disabled={isExporting}>
|
||||
<FileDown size={14} />
|
||||
@ -1084,7 +1422,7 @@ export default function BriefConfigPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{rules.rules.map((rule, index) => (
|
||||
{dynamicPlatformRules.map((rule, index) => (
|
||||
<div key={index}>
|
||||
<p className="text-sm font-medium text-text-primary mb-2">{rule.category}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@ -1172,7 +1510,8 @@ export default function BriefConfigPage() {
|
||||
<div>
|
||||
<p className="text-sm text-accent-green font-medium">配置说明</p>
|
||||
<ul className="text-xs text-accent-green/80 mt-1 space-y-1">
|
||||
<li>• 必选卖点必须在内容中提及</li>
|
||||
<li>• 核心卖点建议优先在内容中体现</li>
|
||||
<li>• 推荐卖点建议提及</li>
|
||||
<li>• 违禁词会触发 AI 审核警告</li>
|
||||
<li>• 此配置将展示给达人查看</li>
|
||||
</ul>
|
||||
@ -1225,7 +1564,7 @@ export default function BriefConfigPage() {
|
||||
{/* 文件预览弹窗(品牌方) */}
|
||||
<Modal
|
||||
isOpen={!!previewFile}
|
||||
onClose={() => { setPreviewFile(null); setPreviewUrl(null) }}
|
||||
onClose={() => { setPreviewFile(null); if (previewUrl) { URL.revokeObjectURL(previewUrl); setPreviewUrl(null) } }}
|
||||
title={previewFile?.name || '文件预览'}
|
||||
size="lg"
|
||||
>
|
||||
@ -1262,7 +1601,7 @@ export default function BriefConfigPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => { setPreviewFile(null); setPreviewUrl(null) }}>
|
||||
<Button variant="secondary" onClick={() => { setPreviewFile(null); if (previewUrl) { URL.revokeObjectURL(previewUrl); setPreviewUrl(null) } }}>
|
||||
关闭
|
||||
</Button>
|
||||
{previewFile && (
|
||||
@ -1337,7 +1676,7 @@ export default function BriefConfigPage() {
|
||||
{/* 代理商文档预览弹窗 */}
|
||||
<Modal
|
||||
isOpen={!!previewAgencyFile}
|
||||
onClose={() => { setPreviewAgencyFile(null); setPreviewAgencyUrl(null) }}
|
||||
onClose={() => { setPreviewAgencyFile(null); if (previewAgencyUrl) { URL.revokeObjectURL(previewAgencyUrl); setPreviewAgencyUrl(null) } }}
|
||||
title={previewAgencyFile?.name || '文件预览'}
|
||||
size="lg"
|
||||
>
|
||||
@ -1374,7 +1713,7 @@ export default function BriefConfigPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => { setPreviewAgencyFile(null); setPreviewAgencyUrl(null) }}>
|
||||
<Button variant="secondary" onClick={() => { setPreviewAgencyFile(null); if (previewAgencyUrl) { URL.revokeObjectURL(previewAgencyUrl); setPreviewAgencyUrl(null) } }}>
|
||||
关闭
|
||||
</Button>
|
||||
{previewAgencyFile && (
|
||||
|
||||
@ -204,8 +204,8 @@ export default function AgencyBriefsPage() {
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Brief 配置</h1>
|
||||
<p className="text-sm text-text-secondary mt-1">配置项目 Brief,设置审核规则</p>
|
||||
<h1 className="text-2xl font-bold text-text-primary">任务配置</h1>
|
||||
<p className="text-sm text-text-secondary mt-1">配置项目 Brief,分配达人任务</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg font-medium">
|
||||
|
||||
@ -477,15 +477,36 @@ export default function AgencyCreatorsPage() {
|
||||
setOpenMenuId(null)
|
||||
}
|
||||
|
||||
// 确认分配项目
|
||||
const handleConfirmAssign = () => {
|
||||
// 确认分配项目(创建任务)
|
||||
const handleConfirmAssign = async () => {
|
||||
const projectList = USE_MOCK ? mockProjects : projects
|
||||
if (assignModal.creator && selectedProject) {
|
||||
const project = projectList.find(p => p.id === selectedProject)
|
||||
if (!assignModal.creator || !selectedProject) return
|
||||
|
||||
const project = projectList.find(p => p.id === selectedProject)
|
||||
|
||||
if (USE_MOCK) {
|
||||
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}」`)
|
||||
setAssignModal({ open: false, creator: null })
|
||||
setSelectedProject('')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await api.createTask({
|
||||
project_id: selectedProject,
|
||||
creator_id: assignModal.creator.creatorId,
|
||||
})
|
||||
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}」`)
|
||||
setAssignModal({ open: false, creator: null })
|
||||
setSelectedProject('')
|
||||
await fetchData() // 刷新列表
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '分配失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
setAssignModal({ open: false, creator: null })
|
||||
setSelectedProject('')
|
||||
}
|
||||
|
||||
// 骨架屏
|
||||
@ -761,43 +782,45 @@ export default function AgencyCreatorsPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-text-tertiary">{creator.joinedAt}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setOpenMenuId(openMenuId === creator.id ? null : creator.id)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenAssign(creator)}
|
||||
className="px-3 py-1.5 text-xs font-medium text-accent-indigo bg-accent-indigo/10 hover:bg-accent-indigo/20 rounded-lg transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</Button>
|
||||
{/* 下拉菜单 */}
|
||||
{openMenuId === creator.id && (
|
||||
<div className="absolute right-0 top-full mt-1 w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenRemark(creator)}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
|
||||
>
|
||||
<MessageSquareText size={14} className="text-text-secondary" />
|
||||
{creator.remark ? '编辑备注' : '添加备注'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenAssign(creator)}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
|
||||
>
|
||||
<FolderPlus size={14} className="text-text-secondary" />
|
||||
分配到项目
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenDelete(creator)}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
移除达人
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<FolderPlus size={13} />
|
||||
分配项目
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenDelete(creator)}
|
||||
className="p-1.5 text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 rounded-lg transition-colors"
|
||||
title="移除达人"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenMenuId(openMenuId === creator.id ? null : creator.id)}
|
||||
className="p-1.5 text-text-tertiary hover:text-text-primary hover:bg-bg-elevated rounded-lg transition-colors"
|
||||
title="更多操作"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
{openMenuId === creator.id && (
|
||||
<div className="absolute right-0 top-full mt-1 w-36 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenRemark(creator)}
|
||||
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
|
||||
>
|
||||
<MessageSquareText size={14} className="text-text-secondary" />
|
||||
{creator.remark ? '编辑备注' : '添加备注'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -1042,8 +1065,8 @@ export default function AgencyCreatorsPage() {
|
||||
<Button variant="ghost" onClick={() => { setAssignModal({ open: false, creator: null }); setSelectedProject(''); }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirmAssign} disabled={!selectedProject}>
|
||||
<FolderPlus size={16} />
|
||||
<Button onClick={handleConfirmAssign} disabled={!selectedProject || submitting}>
|
||||
{submitting ? <Loader2 size={16} className="animate-spin" /> : <FolderPlus size={16} />}
|
||||
确认分配
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -96,8 +96,12 @@ function getTaskUrgencyLevel(task: TaskResponse): string {
|
||||
}
|
||||
|
||||
function getTaskUrgencyTitle(task: TaskResponse): string {
|
||||
const type = task.stage.includes('video') ? '视频' : '脚本'
|
||||
return `${task.creator.name}${type} - ${task.name}`
|
||||
return `${task.project.name} · ${task.name}`
|
||||
}
|
||||
|
||||
function getPlatformLabel(platform?: string | null): string {
|
||||
const map: Record<string, string> = { douyin: '抖音', xiaohongshu: '小红书', bilibili: 'B站', kuaishou: '快手' }
|
||||
return platform ? (map[platform] || platform) : ''
|
||||
}
|
||||
|
||||
function getTaskTimeAgo(dateStr: string): string {
|
||||
@ -182,13 +186,19 @@ export default function AgencyDashboard() {
|
||||
if (loading || !stats) return <DashboardSkeleton />
|
||||
|
||||
// Build urgent todos from pending tasks (top 3)
|
||||
const urgentTodos = pendingTasks.slice(0, 3).map(task => ({
|
||||
id: task.id,
|
||||
title: getTaskUrgencyTitle(task),
|
||||
description: task.project.name,
|
||||
time: getTaskTimeAgo(task.updated_at),
|
||||
level: getTaskUrgencyLevel(task),
|
||||
}))
|
||||
const urgentTodos = pendingTasks.slice(0, 3).map(task => {
|
||||
const type = task.stage.includes('video') ? '视频' : '脚本'
|
||||
const platformLabel = getPlatformLabel(task.project.platform)
|
||||
const brandLabel = task.project.brand_name || ''
|
||||
const desc = [task.creator.name, brandLabel, platformLabel, type].filter(Boolean).join(' · ')
|
||||
return {
|
||||
id: task.id,
|
||||
title: getTaskUrgencyTitle(task),
|
||||
description: desc,
|
||||
time: getTaskTimeAgo(task.updated_at),
|
||||
level: getTaskUrgencyLevel(task),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-h-0">
|
||||
@ -316,6 +326,9 @@ export default function AgencyDashboard() {
|
||||
{project.brand_name && (
|
||||
<span className="text-xs text-text-tertiary">({project.brand_name})</span>
|
||||
)}
|
||||
{project.platform && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-indigo/10 text-accent-indigo">{getPlatformLabel(project.platform)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary">
|
||||
{project.task_count} 个任务
|
||||
@ -356,6 +369,7 @@ export default function AgencyDashboard() {
|
||||
<th className="pb-3 font-medium">类型</th>
|
||||
<th className="pb-3 font-medium">达人</th>
|
||||
<th className="pb-3 font-medium">品牌</th>
|
||||
<th className="pb-3 font-medium">平台</th>
|
||||
<th className="pb-3 font-medium">AI评分</th>
|
||||
<th className="pb-3 font-medium">提交时间</th>
|
||||
<th className="pb-3 font-medium">操作</th>
|
||||
@ -369,7 +383,9 @@ export default function AgencyDashboard() {
|
||||
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
|
||||
<td className="py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-text-primary">{task.name}</div>
|
||||
<div>
|
||||
<div className="font-medium text-text-primary">{task.project.name} · {task.name}</div>
|
||||
</div>
|
||||
{task.is_appeal && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-accent-amber/20 text-accent-amber rounded">
|
||||
申诉
|
||||
@ -385,7 +401,8 @@ export default function AgencyDashboard() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 text-text-secondary">{task.creator.name}</td>
|
||||
<td className="py-4 text-text-secondary">{task.project.brand_name || task.project.name}</td>
|
||||
<td className="py-4 text-text-secondary">{task.project.brand_name || '-'}</td>
|
||||
<td className="py-4 text-text-secondary">{getPlatformLabel(task.project.platform) || '-'}</td>
|
||||
<td className="py-4">
|
||||
{aiScore != null ? (
|
||||
<span className={`font-medium ${
|
||||
@ -409,7 +426,7 @@ export default function AgencyDashboard() {
|
||||
)
|
||||
}) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-text-tertiary">暂无待审核任务</td>
|
||||
<td colSpan={8} className="py-8 text-center text-text-tertiary">暂无待审核任务</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@ -1,577 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { ArrowLeft, Play, Pause, AlertTriangle, Shield, Radio, Loader2 } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
|
||||
import { Modal, ConfirmModal } from '@/components/ui/Modal'
|
||||
import { ReviewSteps, getAgencyReviewSteps } from '@/components/ui/ReviewSteps'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useSSE } from '@/contexts/SSEContext'
|
||||
import type { TaskResponse, AIReviewResult } from '@/types/task'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
const mockTask: TaskResponse = {
|
||||
id: 'task-001',
|
||||
name: '夏日护肤推广',
|
||||
sequence: 1,
|
||||
stage: 'script_agency_review',
|
||||
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX护肤品牌' },
|
||||
agency: { id: 'ag-001', name: '优创代理' },
|
||||
creator: { id: 'cr-001', name: '小美护肤' },
|
||||
script_ai_score: 85,
|
||||
script_ai_result: {
|
||||
score: 85,
|
||||
violations: [
|
||||
{
|
||||
type: '违禁词',
|
||||
content: '效果最好',
|
||||
severity: 'high',
|
||||
suggestion: '建议替换为"效果显著"',
|
||||
timestamp: 15.5,
|
||||
source: 'speech',
|
||||
},
|
||||
{
|
||||
type: '竞品露出',
|
||||
content: '疑似竞品Logo',
|
||||
severity: 'high',
|
||||
suggestion: '需人工确认是否为竞品露出',
|
||||
timestamp: 42.0,
|
||||
source: 'visual',
|
||||
},
|
||||
],
|
||||
soft_warnings: [
|
||||
{ type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' },
|
||||
],
|
||||
summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认',
|
||||
},
|
||||
video_ai_score: 85,
|
||||
video_ai_result: {
|
||||
score: 85,
|
||||
violations: [
|
||||
{
|
||||
type: '违禁词',
|
||||
content: '效果最好',
|
||||
severity: 'high',
|
||||
suggestion: '建议替换为"效果显著"',
|
||||
timestamp: 15.5,
|
||||
source: 'speech',
|
||||
},
|
||||
{
|
||||
type: '竞品露出',
|
||||
content: '疑似竞品Logo',
|
||||
severity: 'high',
|
||||
suggestion: '需人工确认是否为竞品露出',
|
||||
timestamp: 42.0,
|
||||
source: 'visual',
|
||||
},
|
||||
],
|
||||
soft_warnings: [
|
||||
{ type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' },
|
||||
],
|
||||
summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认',
|
||||
},
|
||||
appeal_count: 0,
|
||||
is_appeal: false,
|
||||
created_at: '2026-02-03T10:30:00Z',
|
||||
updated_at: '2026-02-03T10:35:00Z',
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
function getReviewStepStatus(task: TaskResponse): string {
|
||||
if (task.stage.includes('agency_review')) return 'agent_reviewing'
|
||||
if (task.stage.includes('brand_review')) return 'brand_reviewing'
|
||||
if (task.stage === 'completed') return 'completed'
|
||||
return 'agent_reviewing'
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// ==================== 子组件 ====================
|
||||
|
||||
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
||||
const steps = getAgencyReviewSteps(taskStatus)
|
||||
const currentStep = steps.find(s => s.status === 'current')
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-text-primary">审核流程</span>
|
||||
<span className="text-sm text-accent-indigo font-medium">
|
||||
当前:{currentStep?.label || '代理商审核'}
|
||||
</span>
|
||||
</div>
|
||||
<ReviewSteps steps={steps} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskLevelTag({ level }: { level: string }) {
|
||||
if (level === 'high') return <ErrorTag>高风险</ErrorTag>
|
||||
if (level === 'medium') return <WarningTag>中风险</WarningTag>
|
||||
return <SuccessTag>低风险</SuccessTag>
|
||||
}
|
||||
|
||||
function ReviewSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-6 w-48 bg-bg-elevated rounded" />
|
||||
<div className="h-4 w-64 bg-bg-elevated rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<div className="h-64 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-20 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="h-48 bg-bg-elevated rounded-xl" />
|
||||
<div className="h-32 bg-bg-elevated rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 主页面 ====================
|
||||
|
||||
export default function ReviewPage() {
|
||||
/**
|
||||
* Redirect page: detects task type (script/video) and redirects
|
||||
* to the appropriate review detail page.
|
||||
*/
|
||||
export default function ReviewRedirectPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const toast = useToast()
|
||||
const taskId = params.id as string
|
||||
const { subscribe } = useSSE()
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [task, setTask] = useState<TaskResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [showApproveModal, setShowApproveModal] = useState(false)
|
||||
const [showRejectModal, setShowRejectModal] = useState(false)
|
||||
const [showForcePassModal, setShowForcePassModal] = useState(false)
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [forcePassReason, setForcePassReason] = useState('')
|
||||
const [saveAsException, setSaveAsException] = useState(false)
|
||||
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
|
||||
|
||||
const loadTask = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
if (USE_MOCK) {
|
||||
setTask(mockTask)
|
||||
setLoading(false)
|
||||
router.replace(`/agency/review/script/${taskId}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getTask(taskId)
|
||||
setTask(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load task:', err)
|
||||
toast.error('加载任务失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId, toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadTask()
|
||||
}, [loadTask])
|
||||
|
||||
useEffect(() => {
|
||||
const unsub1 = subscribe('task_updated', (data: any) => {
|
||||
if (data?.task_id === taskId) loadTask()
|
||||
})
|
||||
const unsub2 = subscribe('review_completed', (data: any) => {
|
||||
if (data?.task_id === taskId) loadTask()
|
||||
})
|
||||
return () => { unsub1(); unsub2() }
|
||||
}, [subscribe, taskId, loadTask])
|
||||
|
||||
if (loading || !task) return <ReviewSkeleton />
|
||||
|
||||
// Determine if this is script or video review
|
||||
const isVideoReview = task.stage.includes('video')
|
||||
const aiResult: AIReviewResult | null | undefined = isVideoReview ? task.video_ai_result : task.script_ai_result
|
||||
const aiScore = isVideoReview ? task.video_ai_score : task.script_ai_score
|
||||
|
||||
const violations = aiResult?.violations || []
|
||||
const softWarnings = aiResult?.soft_warnings || []
|
||||
const aiSummary = aiResult?.summary || '暂无 AI 分析总结'
|
||||
|
||||
const handleApprove = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
if (isVideoReview) {
|
||||
await api.reviewVideo(taskId, { action: 'pass' })
|
||||
} else {
|
||||
await api.reviewScript(taskId, { action: 'pass' })
|
||||
}
|
||||
async function redirect() {
|
||||
try {
|
||||
const task = await api.getTask(taskId)
|
||||
const isVideo = task.stage.includes('video')
|
||||
const path = isVideo
|
||||
? `/agency/review/video/${taskId}`
|
||||
: `/agency/review/script/${taskId}`
|
||||
router.replace(path)
|
||||
} catch {
|
||||
setError('加载任务失败,请返回重试')
|
||||
}
|
||||
toast.success('审核已通过')
|
||||
setShowApproveModal(false)
|
||||
router.push('/agency/review')
|
||||
} catch (err) {
|
||||
console.error('Failed to approve:', err)
|
||||
toast.error('操作失败,请重试')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
redirect()
|
||||
}, [taskId, router])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="text-accent-indigo hover:underline"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error('请填写驳回原因')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
if (isVideoReview) {
|
||||
await api.reviewVideo(taskId, { action: 'reject', comment: rejectReason })
|
||||
} else {
|
||||
await api.reviewScript(taskId, { action: 'reject', comment: rejectReason })
|
||||
}
|
||||
}
|
||||
toast.success('已驳回')
|
||||
setShowRejectModal(false)
|
||||
router.push('/agency/review')
|
||||
} catch (err) {
|
||||
console.error('Failed to reject:', err)
|
||||
toast.error('操作失败,请重试')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForcePass = async () => {
|
||||
if (!forcePassReason.trim()) {
|
||||
toast.error('请填写强制通过原因')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
if (isVideoReview) {
|
||||
await api.reviewVideo(taskId, { action: 'force_pass', comment: forcePassReason })
|
||||
} else {
|
||||
await api.reviewScript(taskId, { action: 'force_pass', comment: forcePassReason })
|
||||
}
|
||||
}
|
||||
toast.success('已强制通过')
|
||||
setShowForcePassModal(false)
|
||||
router.push('/agency/review')
|
||||
} catch (err) {
|
||||
console.error('Failed to force pass:', err)
|
||||
toast.error('操作失败,请重试')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 时间线标记
|
||||
const timelineMarkers = [
|
||||
...violations.filter(v => v.timestamp != null).map(v => ({ time: v.timestamp!, type: 'hard' as const })),
|
||||
].sort((a, b) => a.time - b.time)
|
||||
|
||||
const maxTime = Math.max(120, ...timelineMarkers.map(m => m.time + 10))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 顶部导航 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-bg-elevated rounded-full">
|
||||
<ArrowLeft size={20} className="text-text-primary" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold text-text-primary">{task.name}</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{task.creator.name} · {task.project.brand_name || task.project.name} · {isVideoReview ? '视频审核' : '脚本审核'}
|
||||
</p>
|
||||
</div>
|
||||
{task.is_appeal && (
|
||||
<span className="px-3 py-1 bg-accent-amber/20 text-accent-amber rounded-full text-sm font-medium">
|
||||
申诉重审
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 申诉理由 */}
|
||||
{task.is_appeal && task.appeal_reason && (
|
||||
<Card className="border-accent-amber/30 bg-accent-amber/5">
|
||||
<CardContent className="py-3">
|
||||
<p className="text-sm text-accent-amber font-medium mb-1">申诉理由</p>
|
||||
<p className="text-sm text-text-secondary">{task.appeal_reason}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 审核流程进度条 */}
|
||||
<ReviewProgressBar taskStatus={getReviewStepStatus(task)} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* 左侧:视频/脚本播放器 (3/5) */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isVideoReview ? (
|
||||
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center relative">
|
||||
<button
|
||||
type="button"
|
||||
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
>
|
||||
{isPlaying ? <Pause size={32} className="text-white" /> : <Play size={32} className="text-white ml-1" />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-[4/3] bg-bg-elevated rounded-t-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-text-secondary">脚本预览区域</p>
|
||||
<p className="text-sm text-text-tertiary mt-1">{task.script_file_name || '脚本文件'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 智能进度条(仅视频且有时间标记时显示) */}
|
||||
{isVideoReview && timelineMarkers.length > 0 && (
|
||||
<div className="p-4 border-t border-border-subtle">
|
||||
<div className="text-sm font-medium text-text-primary mb-3">智能进度条(点击跳转)</div>
|
||||
<div className="relative h-3 bg-bg-elevated rounded-full">
|
||||
{timelineMarkers.map((marker, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-bg-card shadow-md cursor-pointer transition-transform hover:scale-125 ${
|
||||
marker.type === 'hard' ? 'bg-accent-coral' : 'bg-orange-500'
|
||||
}`}
|
||||
style={{ left: `${(marker.time / maxTime) * 100}%` }}
|
||||
title={`${formatTimestamp(marker.time)} - 硬性问题`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-tertiary mt-1">
|
||||
<span>0:00</span>
|
||||
<span>{formatTimestamp(maxTime)}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-accent-coral rounded-full" />
|
||||
硬性问题
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-orange-500 rounded-full" />
|
||||
舆情提示
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-accent-green rounded-full" />
|
||||
卖点覆盖
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI 分析总结 */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-text-primary">AI 分析总结</span>
|
||||
{aiScore != null && (
|
||||
<span className={`text-xl font-bold ${aiScore >= 80 ? 'text-accent-green' : aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral'}`}>
|
||||
{aiScore}分
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">{aiSummary}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧:AI 检查单 (2/5) */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* 硬性合规 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield size={16} className="text-red-500" />
|
||||
硬性合规 ({violations.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{violations.length > 0 ? violations.map((v, idx) => {
|
||||
const key = `v-${idx}`
|
||||
return (
|
||||
<div key={key} className={`p-3 rounded-lg border ${checkedViolations[key] ? 'bg-bg-elevated border-border-subtle' : 'bg-accent-coral/10 border-accent-coral/30'}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedViolations[key] || false}
|
||||
onChange={() => setCheckedViolations((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
className="mt-1 accent-accent-indigo"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ErrorTag>{v.type}</ErrorTag>
|
||||
{v.timestamp != null && (
|
||||
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary">「{v.content}」</p>
|
||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}) : (
|
||||
<div className="text-center py-4 text-text-tertiary text-sm">无硬性违规</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 舆情雷达 */}
|
||||
{softWarnings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Radio size={16} className="text-orange-500" />
|
||||
舆情雷达(仅提示)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{softWarnings.map((w, idx) => (
|
||||
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<WarningTag>{w.type}</WarningTag>
|
||||
</div>
|
||||
<p className="text-sm text-orange-400">{w.content}</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">软性风险仅作提示,不强制拦截</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部决策栏 */}
|
||||
<Card className="sticky bottom-4 shadow-lg">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-text-secondary">
|
||||
已检查 {Object.values(checkedViolations).filter(Boolean).length}/{violations.length} 个问题
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
||||
驳回
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowForcePassModal(true)} disabled={submitting}>
|
||||
强制通过
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
|
||||
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
|
||||
通过
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 通过确认弹窗 */}
|
||||
<ConfirmModal
|
||||
isOpen={showApproveModal}
|
||||
onClose={() => setShowApproveModal(false)}
|
||||
onConfirm={handleApprove}
|
||||
title="确认通过"
|
||||
message={`确定要通过此${isVideoReview ? '视频' : '脚本'}的审核吗?通过后达人将收到通知。`}
|
||||
confirmText="确认通过"
|
||||
/>
|
||||
|
||||
{/* 驳回弹窗 */}
|
||||
<Modal isOpen={showRejectModal} onClose={() => setShowRejectModal(false)} title="驳回审核">
|
||||
<div className="space-y-4">
|
||||
<p className="text-text-secondary text-sm">请填写驳回原因,已勾选的问题将自动打包发送给达人。</p>
|
||||
<div className="p-3 bg-bg-elevated rounded-lg">
|
||||
<p className="text-sm font-medium text-text-primary mb-2">
|
||||
已选问题 ({Object.values(checkedViolations).filter(Boolean).length})
|
||||
</p>
|
||||
{violations.filter((_, idx) => checkedViolations[`v-${idx}`]).map((v, idx) => (
|
||||
<div key={idx} className="text-sm text-text-secondary">- {v.type}: {v.content}</div>
|
||||
))}
|
||||
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
|
||||
<div className="text-sm text-text-tertiary">未选择任何问题</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">补充说明</label>
|
||||
<textarea
|
||||
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||
placeholder="请详细说明驳回原因..."
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}>取消</Button>
|
||||
<Button variant="danger" onClick={handleReject} disabled={submitting}>
|
||||
{submitting && <Loader2 size={16} className="animate-spin" />}
|
||||
确认驳回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 强制通过弹窗 */}
|
||||
<Modal isOpen={showForcePassModal} onClose={() => setShowForcePassModal(false)} title="强制通过">
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
||||
<p className="text-sm text-yellow-400">
|
||||
<AlertTriangle size={14} className="inline mr-1" />
|
||||
强制通过将跳过所有问题检测,操作将被记录
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">放行原因(必填)</label>
|
||||
<textarea
|
||||
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||
placeholder="例如:达人玩的新梗,品牌方认可"
|
||||
value={forcePassReason}
|
||||
onChange={(e) => setForcePassReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveAsException}
|
||||
onChange={(e) => setSaveAsException(e.target.checked)}
|
||||
className="rounded accent-accent-indigo"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">保存为特例(需品牌方确认后生效)</span>
|
||||
</label>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowForcePassModal(false)} disabled={submitting}>取消</Button>
|
||||
<Button onClick={handleForcePass} disabled={submitting}>
|
||||
{submitting && <Loader2 size={16} className="animate-spin" />}
|
||||
确认强制通过
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader2 size={32} className="animate-spin text-accent-indigo" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -24,6 +24,11 @@ import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useSSE } from '@/contexts/SSEContext'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
function platformLabel(id?: string | null): string {
|
||||
if (!id) return ''
|
||||
return getPlatformInfo(id)?.name || id
|
||||
}
|
||||
|
||||
// ==================== Mock 数据 ====================
|
||||
const mockScriptTasks: TaskResponse[] = [
|
||||
{
|
||||
@ -151,7 +156,10 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrev
|
||||
<div className="rounded-xl bg-bg-elevated overflow-hidden">
|
||||
{/* 顶部条 */}
|
||||
<div className="px-4 py-1.5 bg-accent-indigo/10 border-b border-accent-indigo/20 flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-accent-indigo">{task.project.brand_name || task.project.name}</span>
|
||||
<span className="text-xs font-medium text-accent-indigo">{task.project.brand_name || ''}</span>
|
||||
{task.project.platform && (
|
||||
<span className="text-xs text-text-tertiary">· {platformLabel(task.project.platform)}</span>
|
||||
)}
|
||||
{task.is_appeal && (
|
||||
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
|
||||
<MessageSquareWarning size={12} />
|
||||
@ -161,12 +169,13 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrev
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
|
||||
<span className="font-medium text-text-primary">{task.creator.name} · {task.name}</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${riskConfig.color}`} />
|
||||
<span className="font-medium text-text-primary truncate">{task.project.name} · {task.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
|
||||
<span className={`text-xs flex-shrink-0 ${riskConfig.textColor}`}>{riskConfig.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mb-3">达人:{task.creator.name}</p>
|
||||
|
||||
{task.is_appeal && task.appeal_reason && (
|
||||
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
|
||||
@ -195,7 +204,7 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrev
|
||||
<Clock size={12} />
|
||||
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<Link href={`/agency/review/${task.id}`}>
|
||||
<Link href={`/agency/review/script/${task.id}`}>
|
||||
<Button size="sm" className={`${
|
||||
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
|
||||
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
|
||||
@ -227,7 +236,10 @@ function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrevi
|
||||
return (
|
||||
<div className="rounded-xl bg-bg-elevated overflow-hidden">
|
||||
<div className="px-4 py-1.5 bg-purple-500/10 border-b border-purple-500/20 flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-purple-400">{task.project.brand_name || task.project.name}</span>
|
||||
<span className="text-xs font-medium text-purple-400">{task.project.brand_name || ''}</span>
|
||||
{task.project.platform && (
|
||||
<span className="text-xs text-text-tertiary">· {platformLabel(task.project.platform)}</span>
|
||||
)}
|
||||
{task.is_appeal && (
|
||||
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
|
||||
<MessageSquareWarning size={12} />
|
||||
@ -237,12 +249,13 @@ function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrevi
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
|
||||
<span className="font-medium text-text-primary">{task.creator.name} · {task.name}</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${riskConfig.color}`} />
|
||||
<span className="font-medium text-text-primary truncate">{task.project.name} · {task.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
|
||||
<span className={`text-xs flex-shrink-0 ${riskConfig.textColor}`}>{riskConfig.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mb-3">达人:{task.creator.name}</p>
|
||||
|
||||
{task.is_appeal && task.appeal_reason && (
|
||||
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
|
||||
@ -274,7 +287,7 @@ function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrevi
|
||||
<Clock size={12} />
|
||||
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<Link href={`/agency/review/${task.id}`}>
|
||||
<Link href={`/agency/review/video/${task.id}`}>
|
||||
<Button size="sm" className={`${
|
||||
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
|
||||
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { getPlatformInfo } from '@/lib/platforms'
|
||||
import type { TaskResponse } from '@/types/task'
|
||||
|
||||
// 模拟脚本任务数据
|
||||
@ -81,6 +82,8 @@ function mapTaskToViewModel(task: TaskResponse) {
|
||||
title: task.name,
|
||||
creatorName: task.creator?.name || '未知达人',
|
||||
projectName: task.project?.name || '未知项目',
|
||||
brandName: task.project?.brand_name || '',
|
||||
platform: task.project?.platform || '',
|
||||
submittedAt: task.script_uploaded_at || task.created_at,
|
||||
aiScore: task.script_ai_score ?? 0,
|
||||
status: task.stage,
|
||||
@ -107,12 +110,26 @@ function mapTaskToViewModel(task: TaskResponse) {
|
||||
content: v.content,
|
||||
suggestion: v.suggestion,
|
||||
severity: v.severity,
|
||||
dimension: v.dimension,
|
||||
})),
|
||||
complianceChecks: (task.script_ai_result?.soft_warnings || []).map((w) => ({
|
||||
item: w.type,
|
||||
passed: false,
|
||||
note: w.content,
|
||||
})),
|
||||
complianceChecks: (task.script_ai_result?.soft_warnings || []).map((w: any) => {
|
||||
const codeLabels: Record<string, string> = {
|
||||
missing_selling_points: '卖点缺失',
|
||||
tone_mismatch: '语气不符',
|
||||
length_warning: '时长提示',
|
||||
style_warning: '风格提示',
|
||||
sensitive_topic: '敏感话题',
|
||||
audience_mismatch: '受众偏差',
|
||||
}
|
||||
const rawLabel = w.type || w.code || '提示'
|
||||
return {
|
||||
item: codeLabels[rawLabel] || rawLabel,
|
||||
passed: false,
|
||||
note: w.content || w.message || '',
|
||||
}
|
||||
}),
|
||||
dimensions: task.script_ai_result?.dimensions,
|
||||
sellingPointMatches: task.script_ai_result?.selling_point_matches || [],
|
||||
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
|
||||
},
|
||||
aiSummary: task.script_ai_result?.summary || '',
|
||||
@ -183,6 +200,7 @@ export default function AgencyScriptReviewPage() {
|
||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||
const [task, setTask] = useState<ScriptTaskViewModel>(mockScriptTask as unknown as ScriptTaskViewModel)
|
||||
|
||||
|
||||
const loadTask = useCallback(async () => {
|
||||
if (USE_MOCK) return
|
||||
setLoading(true)
|
||||
@ -294,10 +312,9 @@ export default function AgencyScriptReviewPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<User size={14} />
|
||||
{task.creatorName}
|
||||
</span>
|
||||
<span>{task.creatorName}</span>
|
||||
{task.brandName && <span>{task.brandName}</span>}
|
||||
{task.platform && <span>{getPlatformInfo(task.platform)?.name || task.platform}</span>}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{task.submittedAt}
|
||||
@ -368,8 +385,7 @@ export default function AgencyScriptReviewPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText size={18} className="text-accent-indigo" />
|
||||
AI 解析内容
|
||||
<span className="text-xs font-normal text-text-tertiary ml-2">(AI 自动提取的结构化内容)</span>
|
||||
AI 审核分析
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@ -378,23 +394,41 @@ export default function AgencyScriptReviewPage() {
|
||||
<div className="text-xs text-accent-indigo font-medium mb-2">AI 总结</div>
|
||||
<p className="text-text-primary">{task.aiSummary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-accent-indigo font-medium mb-2">开场白</div>
|
||||
<p className="text-text-primary">{task.scriptContent.opening || '(无内容)'}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-purple-400 font-medium mb-2">产品介绍</div>
|
||||
<p className="text-text-primary">{task.scriptContent.productIntro || '(无内容)'}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-orange-400 font-medium mb-2">使用演示</div>
|
||||
<p className="text-text-primary">{task.scriptContent.demo || '(无内容)'}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-accent-green font-medium mb-2">结尾引导</div>
|
||||
<p className="text-text-primary">{task.scriptContent.closing || '(无内容)'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-tertiary text-center py-4">暂无 AI 分析总结</p>
|
||||
)}
|
||||
{task.aiAnalysis.violations.length > 0 && (
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-accent-coral font-medium mb-2">发现问题 ({task.aiAnalysis.violations.length})</div>
|
||||
<div className="space-y-2">
|
||||
{task.aiAnalysis.violations.map((v) => (
|
||||
<div key={v.id} className="text-sm">
|
||||
<span className="text-accent-coral font-medium">[{v.type}]</span>
|
||||
<span className="text-text-primary ml-1">{v.content}</span>
|
||||
<p className="text-xs text-accent-indigo mt-0.5">{v.suggestion}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{task.aiAnalysis.sellingPointMatches.length > 0 && (
|
||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||
<div className="text-xs text-accent-green font-medium mb-2">卖点匹配概览</div>
|
||||
<div className="space-y-1">
|
||||
{task.aiAnalysis.sellingPointMatches.map((sp: { content: string; priority: string; matched: boolean; evidence?: string }, idx: number) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
{sp.matched ? <CheckCircle size={14} className="text-accent-green flex-shrink-0" /> : <XCircle size={14} className="text-accent-coral flex-shrink-0" />}
|
||||
<span className="text-text-primary">{sp.content}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||
'bg-bg-page text-text-tertiary'
|
||||
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@ -414,6 +448,34 @@ export default function AgencyScriptReviewPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 维度评分 */}
|
||||
{task.aiAnalysis.dimensions && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield size={16} className="text-accent-indigo" />
|
||||
维度评分
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
|
||||
const dim = (task.aiAnalysis.dimensions as unknown as Record<string, { score: number; passed: boolean; issue_count: number }>)?.[key]
|
||||
if (!dim) return null
|
||||
const label = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[key]
|
||||
return (
|
||||
<div key={key} className={`flex items-center justify-between p-2 rounded-lg ${dim.passed ? 'bg-accent-green/5' : 'bg-accent-coral/5'}`}>
|
||||
<span className="text-sm text-text-primary">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${dim.passed ? 'text-accent-green' : 'text-accent-coral'}`}>{dim.score}</span>
|
||||
{dim.passed ? <CheckCircle size={14} className="text-accent-green" /> : <XCircle size={14} className="text-accent-coral" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 违规检测 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
@ -427,6 +489,7 @@ export default function AgencyScriptReviewPage() {
|
||||
<div key={v.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<WarningTag>{v.type}</WarningTag>
|
||||
{v.dimension && <span className="text-xs text-text-tertiary">{{ legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[v.dimension as string]}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-text-primary">{v.content}</p>
|
||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||
@ -438,53 +501,58 @@ export default function AgencyScriptReviewPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 合规检查 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield size={16} className="text-accent-indigo" />
|
||||
合规检查
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.aiAnalysis.complianceChecks.map((check, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{check.passed ? (
|
||||
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-text-primary">{check.item}</span>
|
||||
{/* 舆情提示 */}
|
||||
{task.aiAnalysis.complianceChecks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle size={16} className="text-orange-500" />
|
||||
舆情提示(仅参考)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.aiAnalysis.complianceChecks.map((check, idx) => (
|
||||
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<WarningTag>{check.item}</WarningTag>
|
||||
</div>
|
||||
{check.note && (
|
||||
<p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>
|
||||
<p className="text-sm text-text-secondary">{check.note}</p>
|
||||
)}
|
||||
<p className="text-xs text-text-tertiary mt-1">软性风险仅作提示,不影响审核结果</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 卖点覆盖 */}
|
||||
{/* 卖点匹配 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
卖点覆盖
|
||||
卖点匹配
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.aiAnalysis.sellingPoints.map((sp, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{sp.covered ? (
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-accent-coral" />
|
||||
)}
|
||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||
</div>
|
||||
))}
|
||||
{task.aiAnalysis.sellingPoints.length === 0 && (
|
||||
{task.aiAnalysis.sellingPointMatches && task.aiAnalysis.sellingPointMatches.length > 0 ? (
|
||||
task.aiAnalysis.sellingPointMatches.map((sp: { content: string; priority: string; matched: boolean; evidence?: string }, idx: number) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{sp.matched ? <CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-primary">{sp.content}</span>
|
||||
<span className={`px-1.5 py-0.5 text-xs rounded ${
|
||||
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||
'bg-bg-page text-text-tertiary'
|
||||
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
|
||||
</div>
|
||||
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-text-tertiary text-center py-4">暂无卖点数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@ -497,7 +565,9 @@ export default function AgencyScriptReviewPage() {
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-text-secondary">
|
||||
项目:{task.projectName}
|
||||
{task.brandName && <span>{task.brandName} · </span>}
|
||||
{task.projectName}
|
||||
{task.platform && <span> · {getPlatformInfo(task.platform)?.name || task.platform}</span>}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
||||
|
||||
@ -221,6 +221,7 @@ export default function AgencyVideoReviewPage() {
|
||||
const [videoError, setVideoError] = useState(false)
|
||||
const [task, setTask] = useState<VideoTaskViewModel>(mockVideoTask as unknown as VideoTaskViewModel)
|
||||
|
||||
|
||||
const loadTask = useCallback(async () => {
|
||||
if (USE_MOCK) return
|
||||
setLoading(true)
|
||||
|
||||
@ -109,9 +109,16 @@ export default function AIConfigPage() {
|
||||
if (config.available_models && Object.keys(config.available_models).length > 0) {
|
||||
setAvailableModels(config.available_models)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load AI config:', err)
|
||||
toast.error('加载 AI 配置失败')
|
||||
} catch (err: any) {
|
||||
// 后端 404 返回 "AI 服务未配置" → Axios 拦截器转为 Error(message)
|
||||
// 这是正常的"尚未配置"状态,不弹错误
|
||||
const msg = err?.message || ''
|
||||
if (msg.includes('未配置')) {
|
||||
setIsConfigured(false)
|
||||
} else {
|
||||
console.error('Failed to load AI config:', err)
|
||||
toast.error('加载 AI 配置失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
Calendar,
|
||||
Users,
|
||||
FileText,
|
||||
Video,
|
||||
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
@ -30,6 +30,7 @@ import {
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { getPlatformInfo } from '@/lib/platforms'
|
||||
import { mapTaskToUI } from '@/lib/taskStageMapper'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useSSE } from '@/contexts/SSEContext'
|
||||
@ -77,6 +78,24 @@ const mockTasks: TaskResponse[] = [
|
||||
appeal_count: 0, is_appeal: false,
|
||||
created_at: '2026-02-06T10:00:00Z', updated_at: '2026-02-06T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'task-004', name: '美白精华测评', sequence: 4,
|
||||
stage: 'script_agency_review',
|
||||
project: { id: 'proj-001', name: 'XX品牌618推广' },
|
||||
agency: { id: 'AG456789', name: '创意无限' },
|
||||
creator: { id: 'cr-004', name: '时尚小王' },
|
||||
appeal_count: 0, is_appeal: false,
|
||||
created_at: '2026-02-07T09:00:00Z', updated_at: '2026-02-07T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'task-005', name: '防晒霜种草', sequence: 5,
|
||||
stage: 'script_upload',
|
||||
project: { id: 'proj-001', name: 'XX品牌618推广' },
|
||||
agency: { id: 'AG789012', name: '星耀传媒' },
|
||||
creator: { id: 'cr-001', name: '小美护肤' },
|
||||
appeal_count: 0, is_appeal: false,
|
||||
created_at: '2026-02-07T11:00:00Z', updated_at: '2026-02-07T11:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const mockManagedAgencies: AgencyDetail[] = [
|
||||
@ -136,6 +155,137 @@ function DetailSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 任务进度条 ====================
|
||||
|
||||
const SCRIPT_STEPS = [
|
||||
{ key: 'script_upload', label: '上传' },
|
||||
{ key: 'script_ai_review', label: 'AI' },
|
||||
{ key: 'script_agency_review', label: '代理商' },
|
||||
{ key: 'script_brand_review', label: '品牌' },
|
||||
]
|
||||
|
||||
const VIDEO_STEPS = [
|
||||
{ key: 'video_upload', label: '上传' },
|
||||
{ key: 'video_ai_review', label: 'AI' },
|
||||
{ key: 'video_agency_review', label: '代理商' },
|
||||
{ key: 'video_brand_review', label: '品牌' },
|
||||
]
|
||||
|
||||
function StepDot({ status }: { status: 'done' | 'current' | 'error' | 'pending' }) {
|
||||
const base = 'w-3 h-3 rounded-full border-2 flex-shrink-0'
|
||||
if (status === 'done') return <div className={`${base} bg-accent-green border-accent-green`} />
|
||||
if (status === 'current') return <div className={`${base} bg-accent-indigo border-accent-indigo animate-pulse`} />
|
||||
if (status === 'error') return <div className={`${base} bg-accent-coral border-accent-coral`} />
|
||||
return <div className={`${base} bg-transparent border-border-strong`} />
|
||||
}
|
||||
|
||||
function StepLine({ status }: { status: 'done' | 'pending' | 'error' }) {
|
||||
if (status === 'done') return <div className="w-4 h-0.5 bg-accent-green mx-0.5" />
|
||||
if (status === 'error') return <div className="w-4 h-0.5 bg-accent-coral mx-0.5" />
|
||||
return <div className="w-4 h-0.5 bg-border-strong mx-0.5" />
|
||||
}
|
||||
|
||||
function TaskProgressBar({ task }: { task: TaskResponse }) {
|
||||
const ui = mapTaskToUI(task)
|
||||
|
||||
const scriptStatuses: Array<'done' | 'current' | 'error' | 'pending'> = [
|
||||
ui.scriptStage.submit, ui.scriptStage.ai, ui.scriptStage.agency, ui.scriptStage.brand,
|
||||
]
|
||||
const videoStatuses: Array<'done' | 'current' | 'error' | 'pending'> = [
|
||||
ui.videoStage.submit, ui.videoStage.ai, ui.videoStage.agency, ui.videoStage.brand,
|
||||
]
|
||||
|
||||
const isCompleted = task.stage === 'completed'
|
||||
const isRejected = task.stage === 'rejected'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 脚本阶段 */}
|
||||
<div className="flex items-center gap-0">
|
||||
<span className="text-[10px] text-text-tertiary mr-1.5 w-6">脚本</span>
|
||||
{SCRIPT_STEPS.map((step, i) => (
|
||||
<div key={step.key} className="flex items-center">
|
||||
<div className="relative group">
|
||||
<StepDot status={scriptStatuses[i]} />
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 bg-bg-card border border-border-subtle rounded text-[10px] text-text-secondary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
|
||||
{step.label}
|
||||
</div>
|
||||
</div>
|
||||
{i < SCRIPT_STEPS.length - 1 && (
|
||||
<StepLine status={scriptStatuses[i] === 'done' ? 'done' : scriptStatuses[i] === 'error' ? 'error' : 'pending'} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="w-3 h-0.5 bg-border-subtle mx-0.5" />
|
||||
|
||||
{/* 视频阶段 */}
|
||||
<div className="flex items-center gap-0">
|
||||
<span className="text-[10px] text-text-tertiary mr-1.5 w-6">视频</span>
|
||||
{VIDEO_STEPS.map((step, i) => (
|
||||
<div key={step.key} className="flex items-center">
|
||||
<div className="relative group">
|
||||
<StepDot status={videoStatuses[i]} />
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 bg-bg-card border border-border-subtle rounded text-[10px] text-text-secondary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
|
||||
{step.label}
|
||||
</div>
|
||||
</div>
|
||||
{i < VIDEO_STEPS.length - 1 && (
|
||||
<StepLine status={videoStatuses[i] === 'done' ? 'done' : videoStatuses[i] === 'error' ? 'error' : 'pending'} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 完成标记 */}
|
||||
<div className="ml-1.5">
|
||||
{isCompleted ? (
|
||||
<CheckCircle size={14} className="text-accent-green" />
|
||||
) : isRejected ? (
|
||||
<XCircle size={14} className="text-accent-coral" />
|
||||
) : (
|
||||
<div className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskGroup {
|
||||
agencyId: string
|
||||
agencyName: string
|
||||
creators: {
|
||||
creatorId: string
|
||||
creatorName: string
|
||||
tasks: TaskResponse[]
|
||||
}[]
|
||||
}
|
||||
|
||||
function groupTasksByAgencyCreator(tasks: TaskResponse[]): TaskGroup[] {
|
||||
const agencyMap = new Map<string, TaskGroup>()
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!agencyMap.has(task.agency.id)) {
|
||||
agencyMap.set(task.agency.id, {
|
||||
agencyId: task.agency.id,
|
||||
agencyName: task.agency.name,
|
||||
creators: [],
|
||||
})
|
||||
}
|
||||
const group = agencyMap.get(task.agency.id)!
|
||||
let creator = group.creators.find(c => c.creatorId === task.creator.id)
|
||||
if (!creator) {
|
||||
creator = { creatorId: task.creator.id, creatorName: task.creator.name, tasks: [] }
|
||||
group.creators.push(creator)
|
||||
}
|
||||
creator.tasks.push(task)
|
||||
}
|
||||
|
||||
return Array.from(agencyMap.values())
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
@ -144,7 +294,7 @@ export default function ProjectDetailPage() {
|
||||
const { subscribe } = useSSE()
|
||||
|
||||
const [project, setProject] = useState<ProjectResponse | null>(null)
|
||||
const [recentTasks, setRecentTasks] = useState<TaskResponse[]>([])
|
||||
const [allTasks, setAllTasks] = useState<TaskResponse[]>([])
|
||||
const [managedAgencies, setManagedAgencies] = useState<AgencyDetail[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@ -162,7 +312,7 @@ export default function ProjectDetailPage() {
|
||||
const loadData = useCallback(async () => {
|
||||
if (USE_MOCK) {
|
||||
setProject(mockProject)
|
||||
setRecentTasks(mockTasks)
|
||||
setAllTasks(mockTasks)
|
||||
setManagedAgencies(mockManagedAgencies)
|
||||
setLoading(false)
|
||||
return
|
||||
@ -171,11 +321,11 @@ export default function ProjectDetailPage() {
|
||||
try {
|
||||
const [projectData, tasksData, agenciesData] = await Promise.all([
|
||||
api.getProject(projectId),
|
||||
api.listTasks(1, 10),
|
||||
api.listTasks(1, 100, undefined, projectId),
|
||||
api.listBrandAgencies(),
|
||||
])
|
||||
setProject(projectData)
|
||||
setRecentTasks(tasksData.items.filter(t => t.project.id === projectId).slice(0, 5))
|
||||
setAllTasks(tasksData.items)
|
||||
setManagedAgencies(agenciesData.items)
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
@ -337,54 +487,74 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 最近任务 */}
|
||||
{/* 任务进度 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>最近提交</span>
|
||||
<span>任务进度</span>
|
||||
<Link href="/brand/review">
|
||||
<Button variant="ghost" size="sm">
|
||||
查看全部 <ChevronRight size={16} />
|
||||
审核列表 <ChevronRight size={16} />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentTasks.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary">
|
||||
<th className="pb-3 font-medium">任务</th>
|
||||
<th className="pb-3 font-medium">达人</th>
|
||||
<th className="pb-3 font-medium">代理商</th>
|
||||
<th className="pb-3 font-medium">状态</th>
|
||||
<th className="pb-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentTasks.map((task) => (
|
||||
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
|
||||
<td className="py-4 font-medium text-text-primary">{task.name}</td>
|
||||
<td className="py-4 text-text-secondary">{task.creator.name}</td>
|
||||
<td className="py-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-bg-elevated text-sm">
|
||||
<Building2 size={14} className="text-accent-indigo" />
|
||||
<span className="text-text-secondary">{task.agency.name}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4"><TaskStatusTag stage={task.stage} /></td>
|
||||
<td className="py-4">
|
||||
<Link href={`/agency/review/${task.id}`}>
|
||||
<Button size="sm" variant={task.stage.includes('review') ? 'primary' : 'secondary'}>
|
||||
{task.stage.includes('review') ? '审核' : '查看'}
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{allTasks.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* 图例 */}
|
||||
<div className="flex items-center gap-4 text-[10px] text-text-tertiary pb-2 border-b border-border-subtle">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent-green inline-block" /> 已完成</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent-indigo inline-block" /> 进行中</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full border border-border-strong inline-block" /> 待处理</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent-coral inline-block" /> 已驳回</span>
|
||||
</div>
|
||||
|
||||
{groupTasksByAgencyCreator(allTasks).map((group) => (
|
||||
<div key={group.agencyId} className="space-y-2">
|
||||
{/* 代理商标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 size={14} className="text-accent-indigo" />
|
||||
<span className="text-sm font-medium text-text-primary">{group.agencyName}</span>
|
||||
<span className="text-xs text-text-tertiary">
|
||||
({group.creators.reduce((sum, c) => sum + c.tasks.length, 0)} 个任务)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 达人列表 */}
|
||||
<div className="ml-4 space-y-1">
|
||||
{group.creators.map((creator) => (
|
||||
<div key={creator.creatorId} className="space-y-1">
|
||||
{/* 达人名称 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 rounded-full bg-accent-green/15 flex items-center justify-center">
|
||||
<Users size={10} className="text-accent-green" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-text-secondary">{creator.creatorName}</span>
|
||||
</div>
|
||||
|
||||
{/* 任务进度条 */}
|
||||
<div className="ml-6 space-y-1.5">
|
||||
{creator.tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center gap-3 py-1.5 px-2 rounded-lg hover:bg-bg-elevated group/task">
|
||||
<span className="text-xs text-text-primary min-w-[80px] truncate font-medium">{task.name}</span>
|
||||
<TaskProgressBar task={task} />
|
||||
<span className="text-[10px] text-text-tertiary ml-auto hidden group-hover/task:block">
|
||||
<TaskStatusTag stage={task.stage} />
|
||||
</span>
|
||||
<Link href={task.stage.includes('video') ? `/brand/review/video/${task.id}` : `/brand/review/script/${task.id}`}>
|
||||
<button type="button" className="text-xs text-accent-indigo hover:text-accent-indigo/80 opacity-0 group-hover/task:opacity-100 transition-opacity">
|
||||
{task.stage.includes('brand_review') ? '审核' : '查看'}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-tertiary text-sm">暂无任务</div>
|
||||
|
||||
@ -173,12 +173,12 @@ function mapTaskToUI(task: TaskResponse, type: 'script' | 'video'): UITask {
|
||||
// 格式化提交时间
|
||||
const submittedAt = formatDateTime(task.updated_at)
|
||||
|
||||
// 平台信息:后端目前不返回平台字段,默认 douyin
|
||||
const platform = 'douyin'
|
||||
// 平台信息:从项目获取
|
||||
const platform = task.project.platform || ''
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.name,
|
||||
title: `${task.project.name} · ${task.name}`,
|
||||
fileName,
|
||||
fileSize: isScript ? '--' : '--',
|
||||
creatorName: task.creator.name,
|
||||
|
||||
@ -63,7 +63,7 @@ const mockScriptTask = {
|
||||
},
|
||||
aiAnalysis: {
|
||||
violations: [
|
||||
{ id: 'v1', type: '违禁词', content: '神器', suggestion: '建议替换为"好物"或"必备品"', severity: 'medium' },
|
||||
{ id: 'v1', type: '违禁词', content: '神器', suggestion: '建议替换为"好物"或"必备品"', severity: 'medium', dimension: 'legal' },
|
||||
],
|
||||
complianceChecks: [
|
||||
{ item: '品牌名称正确', passed: true },
|
||||
@ -71,6 +71,17 @@ const mockScriptTask = {
|
||||
{ item: '无绝对化用语', passed: false, note: '"超级好用"建议修改' },
|
||||
{ item: '引导语规范', passed: true },
|
||||
],
|
||||
dimensions: {
|
||||
legal: { score: 85, passed: true, issue_count: 1 },
|
||||
platform: { score: 100, passed: true, issue_count: 0 },
|
||||
brand_safety: { score: 100, passed: true, issue_count: 0 },
|
||||
brief_match: { score: 100, passed: true, issue_count: 0 },
|
||||
},
|
||||
sellingPointMatches: [
|
||||
{ content: 'SPF50+ PA++++', priority: 'core' as const, matched: true, evidence: '脚本提及 SPF50+,PA++++' },
|
||||
{ content: '轻薄质地', priority: 'core' as const, matched: true, evidence: '脚本描述质地轻薄不油腻' },
|
||||
{ content: '延展性好', priority: 'recommended' as const, matched: true, evidence: '脚本演示延展性' },
|
||||
],
|
||||
sellingPoints: [
|
||||
{ point: 'SPF50+ PA++++', covered: true },
|
||||
{ point: '轻薄质地', covered: true },
|
||||
@ -88,6 +99,7 @@ function mapTaskToView(task: TaskResponse) {
|
||||
content: v.content,
|
||||
suggestion: v.suggestion,
|
||||
severity: v.severity,
|
||||
dimension: v.dimension,
|
||||
}))
|
||||
|
||||
const softWarnings = (task.script_ai_result?.soft_warnings || []).map((w, idx) => ({
|
||||
@ -138,6 +150,8 @@ function mapTaskToView(task: TaskResponse) {
|
||||
aiAnalysis: {
|
||||
violations,
|
||||
softWarnings,
|
||||
dimensions: task.script_ai_result?.dimensions,
|
||||
sellingPointMatches: task.script_ai_result?.selling_point_matches || [],
|
||||
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
|
||||
},
|
||||
}
|
||||
@ -236,6 +250,7 @@ export default function BrandScriptReviewPage() {
|
||||
},
|
||||
} : taskData
|
||||
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (USE_MOCK) {
|
||||
setShowApproveModal(false)
|
||||
@ -492,6 +507,34 @@ export default function BrandScriptReviewPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 维度评分 */}
|
||||
{task.aiAnalysis.dimensions && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Shield size={16} className="text-accent-indigo" />
|
||||
维度评分
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
|
||||
const dim = (task.aiAnalysis.dimensions as unknown as Record<string, { score: number; passed: boolean; issue_count: number }>)?.[key]
|
||||
if (!dim) return null
|
||||
const label = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[key]
|
||||
return (
|
||||
<div key={key} className={`flex items-center justify-between p-2 rounded-lg ${dim.passed ? 'bg-accent-green/5' : 'bg-accent-coral/5'}`}>
|
||||
<span className="text-sm text-text-primary">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${dim.passed ? 'text-accent-green' : 'text-accent-coral'}`}>{dim.score}</span>
|
||||
{dim.passed ? <CheckCircle size={14} className="text-accent-green" /> : <XCircle size={14} className="text-accent-coral" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 违规检测 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
@ -505,6 +548,7 @@ export default function BrandScriptReviewPage() {
|
||||
<div key={v.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<WarningTag>{v.type}</WarningTag>
|
||||
{v.dimension && <span className="text-xs text-text-tertiary">{{ legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[v.dimension as string]}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-text-primary">{v.content}</p>
|
||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||
@ -568,26 +612,41 @@ export default function BrandScriptReviewPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 卖点覆盖 */}
|
||||
{task.aiAnalysis.sellingPoints.length > 0 && (
|
||||
{/* 卖点匹配 */}
|
||||
{(task.aiAnalysis.sellingPointMatches?.length > 0 || task.aiAnalysis.sellingPoints.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
卖点覆盖
|
||||
卖点匹配
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{task.aiAnalysis.sellingPoints.map((sp, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{sp.covered ? (
|
||||
<CheckCircle size={16} className="text-accent-green" />
|
||||
) : (
|
||||
<XCircle size={16} className="text-accent-coral" />
|
||||
)}
|
||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||
</div>
|
||||
))}
|
||||
{task.aiAnalysis.sellingPointMatches && task.aiAnalysis.sellingPointMatches.length > 0 ? (
|
||||
task.aiAnalysis.sellingPointMatches.map((sp: { content: string; priority: string; matched: boolean; evidence?: string }, idx: number) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{sp.matched ? <CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-primary">{sp.content}</span>
|
||||
<span className={`px-1.5 py-0.5 text-xs rounded ${
|
||||
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||
'bg-bg-page text-text-tertiary'
|
||||
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
|
||||
</div>
|
||||
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
task.aiAnalysis.sellingPoints.map((sp, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{sp.covered ? <CheckCircle size={16} className="text-accent-green" /> : <XCircle size={16} className="text-accent-coral" />}
|
||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@ -316,6 +316,7 @@ export default function BrandVideoReviewPage() {
|
||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||
const [videoError, setVideoError] = useState(false)
|
||||
|
||||
|
||||
// 加载任务数据
|
||||
const loadTask = useCallback(async () => {
|
||||
if (!taskId) return
|
||||
|
||||
@ -582,8 +582,12 @@ export default function RulesPage() {
|
||||
const handleDeleteWhitelist = async (id: string) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (USE_MOCK) { setWhitelist(prev => prev.filter(w => w.id !== id)) }
|
||||
else { setWhitelist(prev => prev.filter(w => w.id !== id)) }
|
||||
if (USE_MOCK) {
|
||||
setWhitelist(prev => prev.filter(w => w.id !== id))
|
||||
} else {
|
||||
await api.deleteWhitelistItem(id)
|
||||
await loadWhitelist()
|
||||
}
|
||||
toast.success('白名单已删除')
|
||||
} catch (err) {
|
||||
toast.error('删除白名单失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||
|
||||
@ -120,7 +120,7 @@ function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefV
|
||||
const sellingPoints = (brief.selling_points ?? []).map((sp, idx) => ({
|
||||
id: `sp-${idx}`,
|
||||
content: sp.content,
|
||||
required: sp.required,
|
||||
required: sp.required ?? (sp.priority === 'core'),
|
||||
}))
|
||||
|
||||
// Map blacklist words
|
||||
@ -244,10 +244,9 @@ export default function TaskBriefPage() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const signedUrl = await api.getSignedUrl(file.url)
|
||||
window.open(signedUrl, '_blank')
|
||||
await api.downloadFile(file.url, file.name)
|
||||
} catch {
|
||||
toast.error('获取下载链接失败')
|
||||
toast.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useSSE } from '@/contexts/SSEContext'
|
||||
import type { TaskResponse, AIReviewResult } from '@/types/task'
|
||||
import type { TaskResponse, AIReviewResult, ReviewDimensions, SellingPointMatchResult, BriefMatchDetail } from '@/types/task'
|
||||
import type { BriefResponse } from '@/types/brief'
|
||||
|
||||
// 前端 UI 使用的任务阶段类型
|
||||
@ -57,6 +57,15 @@ type TaskData = {
|
||||
rejectionReason?: string
|
||||
submittedAt?: string
|
||||
scriptContent?: string
|
||||
aiResult?: {
|
||||
score: number
|
||||
dimensions?: ReviewDimensions
|
||||
sellingPointMatches?: SellingPointMatchResult[]
|
||||
briefMatchDetail?: BriefMatchDetail
|
||||
violations: Array<{ type: string; content: string; suggestion: string; dimension?: string }>
|
||||
}
|
||||
agencyReview?: { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
|
||||
brandReview?: { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
|
||||
}
|
||||
|
||||
type AgencyBriefFile = {
|
||||
@ -134,8 +143,9 @@ function mapApiTaskToTaskData(task: TaskResponse): TaskData {
|
||||
// 提取 AI 审核结果中的 issues
|
||||
const aiResult = phase === 'script' ? task.script_ai_result : task.video_ai_result
|
||||
if (aiResult?.violations) {
|
||||
const dimLabels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
|
||||
issues = aiResult.violations.map(v => ({
|
||||
title: v.type,
|
||||
title: v.dimension ? `[${dimLabels[v.dimension] || v.dimension}] ${v.type}` : v.type,
|
||||
description: `${v.content}${v.suggestion ? ` — ${v.suggestion}` : ''}`,
|
||||
timestamp: v.timestamp ? `${v.timestamp}s` : undefined,
|
||||
severity: v.severity === 'warning' ? 'warning' as const : 'error' as const,
|
||||
@ -144,6 +154,35 @@ function mapApiTaskToTaskData(task: TaskResponse): TaskData {
|
||||
|
||||
const subtitle = `${task.project.name} · ${task.project.brand_name || ''}`
|
||||
|
||||
// AI 审核结果(完整,含维度)
|
||||
const aiResultData = aiResult ? {
|
||||
score: aiResult.score,
|
||||
dimensions: aiResult.dimensions,
|
||||
sellingPointMatches: aiResult.selling_point_matches,
|
||||
briefMatchDetail: aiResult.brief_match_detail,
|
||||
violations: aiResult.violations.map(v => ({ type: v.type, content: v.content, suggestion: v.suggestion, dimension: v.dimension })),
|
||||
} : undefined
|
||||
|
||||
// 代理商审核反馈
|
||||
const agencyStatus = phase === 'script' ? task.script_agency_status : task.video_agency_status
|
||||
const agencyComment = phase === 'script' ? task.script_agency_comment : task.video_agency_comment
|
||||
const agencyReview = agencyStatus && agencyStatus !== 'pending' ? {
|
||||
result: (agencyStatus === 'passed' || agencyStatus === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
|
||||
comment: agencyComment || '',
|
||||
reviewer: task.agency?.name || '代理商',
|
||||
time: task.updated_at,
|
||||
} : undefined
|
||||
|
||||
// 品牌方审核反馈
|
||||
const brandStatus = phase === 'script' ? task.script_brand_status : task.video_brand_status
|
||||
const brandComment = phase === 'script' ? task.script_brand_comment : task.video_brand_comment
|
||||
const brandReview = brandStatus && brandStatus !== 'pending' ? {
|
||||
result: (brandStatus === 'passed' || brandStatus === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
|
||||
comment: brandComment || '',
|
||||
reviewer: '品牌方审核员',
|
||||
time: task.updated_at,
|
||||
} : undefined
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.name,
|
||||
@ -153,6 +192,9 @@ function mapApiTaskToTaskData(task: TaskResponse): TaskData {
|
||||
issues: issues.length > 0 ? issues : undefined,
|
||||
rejectionReason,
|
||||
submittedAt,
|
||||
aiResult: aiResultData,
|
||||
agencyReview,
|
||||
brandReview,
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,11 +206,12 @@ const mockBriefData = {
|
||||
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
|
||||
] as AgencyBriefFile[],
|
||||
sellingPoints: [
|
||||
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
|
||||
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
|
||||
{ id: 'sp3', content: '延展性好,易推开', required: false },
|
||||
{ id: 'sp4', content: '适合敏感肌', required: false },
|
||||
{ id: 'sp5', content: '夏日必备防晒', required: true },
|
||||
{ id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const },
|
||||
{ id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const },
|
||||
{ id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const },
|
||||
{ id: 'sp4', content: '适合敏感肌', priority: 'recommended' as const },
|
||||
{ id: 'sp5', content: '夏日必备防晒', priority: 'core' as const },
|
||||
{ id: 'sp6', content: '产品成分天然', priority: 'reference' as const },
|
||||
],
|
||||
blacklistWords: [
|
||||
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
|
||||
@ -278,15 +321,16 @@ function ReviewProgressBar({ task }: { task: TaskData }) {
|
||||
// Brief 组件
|
||||
function AgencyBriefSection({ toast, briefData }: {
|
||||
toast: ReturnType<typeof useToast>
|
||||
briefData: { files: AgencyBriefFile[]; sellingPoints: { id: string; content: string; required: boolean }[]; blacklistWords: { id: string; word: string; reason: string }[] }
|
||||
briefData: { files: AgencyBriefFile[]; sellingPoints: { id: string; content: string; priority: 'core' | 'recommended' | 'reference' }[]; blacklistWords: { id: string; word: string; reason: string }[] }
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
|
||||
|
||||
const handleDownload = (file: AgencyBriefFile) => { toast.info(`下载文件: ${file.name}`) }
|
||||
|
||||
const requiredPoints = briefData.sellingPoints.filter(sp => sp.required)
|
||||
const optionalPoints = briefData.sellingPoints.filter(sp => !sp.required)
|
||||
const corePoints = briefData.sellingPoints.filter(sp => sp.priority === 'core')
|
||||
const recommendedPoints = briefData.sellingPoints.filter(sp => sp.priority === 'recommended')
|
||||
const referencePoints = briefData.sellingPoints.filter(sp => sp.priority === 'reference')
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -337,21 +381,31 @@ function AgencyBriefSection({ toast, briefData }: {
|
||||
<Target className="w-4 h-4 text-accent-green" /> 卖点要求
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{requiredPoints.length > 0 && (
|
||||
{corePoints.length > 0 && (
|
||||
<div className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
|
||||
<p className="text-xs text-accent-coral font-medium mb-2">必选卖点(必须提及)</p>
|
||||
<p className="text-xs text-accent-coral font-medium mb-2">核心卖点(建议优先提及)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{requiredPoints.map((sp) => (
|
||||
{corePoints.map((sp) => (
|
||||
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded-lg">{sp.content}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{optionalPoints.length > 0 && (
|
||||
<div className="p-3 bg-bg-elevated rounded-xl">
|
||||
<p className="text-xs text-text-tertiary font-medium mb-2">可选卖点</p>
|
||||
{recommendedPoints.length > 0 && (
|
||||
<div className="p-3 bg-accent-amber/10 rounded-xl border border-accent-amber/30">
|
||||
<p className="text-xs text-accent-amber font-medium mb-2">推荐卖点(建议提及)</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{optionalPoints.map((sp) => (
|
||||
{recommendedPoints.map((sp) => (
|
||||
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-lg">{sp.content}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{referencePoints.length > 0 && (
|
||||
<div className="p-3 bg-bg-elevated rounded-xl">
|
||||
<p className="text-xs text-text-tertiary font-medium mb-2">参考信息</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{referencePoints.map((sp) => (
|
||||
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded-lg">{sp.content}</span>
|
||||
))}
|
||||
</div>
|
||||
@ -394,42 +448,290 @@ function AgencyBriefSection({ toast, briefData }: {
|
||||
)
|
||||
}
|
||||
|
||||
function UploadView({ task, toast, briefData }: { task: TaskData; toast: ReturnType<typeof useToast>; briefData: typeof mockBriefData }) {
|
||||
const router = useRouter()
|
||||
const { id } = useParams()
|
||||
const isScript = task.phase === 'script'
|
||||
const uploadPath = isScript ? `/creator/task/${id}/script` : `/creator/task/${id}/video`
|
||||
function FileUploadSection({ taskId, phase, onUploaded }: { taskId: string; phase: 'script' | 'video'; onUploaded: () => void }) {
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const toast = useToast()
|
||||
const isScript = phase === 'script'
|
||||
|
||||
const handleUploadClick = () => {
|
||||
router.push(uploadPath)
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) { setFile(selectedFile); setUploadError(null) }
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) return
|
||||
setIsUploading(true); setProgress(0); setUploadError(null)
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
for (let i = 0; i <= 100; i += 20) { await new Promise(r => setTimeout(r, 400)); setProgress(i) }
|
||||
toast.success(isScript ? '脚本已提交,等待 AI 审核' : '视频已提交,等待 AI 审核')
|
||||
onUploaded()
|
||||
} else {
|
||||
const result = await api.proxyUpload(file, isScript ? 'script' : 'video', (pct) => {
|
||||
setProgress(Math.min(90, Math.round(pct * 0.9)))
|
||||
})
|
||||
setProgress(95)
|
||||
if (isScript) {
|
||||
await api.uploadTaskScript(taskId, { file_url: result.url, file_name: result.file_name })
|
||||
} else {
|
||||
await api.uploadTaskVideo(taskId, { file_url: result.url, file_name: result.file_name })
|
||||
}
|
||||
setProgress(100)
|
||||
toast.success(isScript ? '脚本已提交,等待 AI 审核' : '视频已提交,等待 AI 审核')
|
||||
onUploaded()
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '上传失败'
|
||||
setUploadError(msg); toast.error(msg)
|
||||
} finally { setIsUploading(false) }
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + 'B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
|
||||
}
|
||||
|
||||
const acceptTypes = isScript ? '.doc,.docx,.pdf,.txt,.xls,.xlsx' : '.mp4,.mov,.avi,.mkv'
|
||||
const acceptHint = isScript ? '支持 Word、PDF、TXT、Excel 格式' : '支持 MP4/MOV 格式,≤ 100MB'
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card rounded-2xl card-shadow">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle">
|
||||
<Upload className="w-5 h-5 text-accent-indigo" />
|
||||
<span className="text-base font-semibold text-text-primary">{isScript ? '上传脚本' : '上传视频'}</span>
|
||||
<span className="ml-auto px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-indigo/15 text-accent-indigo">待提交</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{!file ? (
|
||||
<label className="border-2 border-dashed border-border-subtle rounded-xl p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
|
||||
<Upload className="w-8 h-8 mx-auto text-text-tertiary mb-3" />
|
||||
<p className="text-text-secondary mb-1">点击选择{isScript ? '脚本' : '视频'}文件</p>
|
||||
<p className="text-xs text-text-tertiary">{acceptHint}</p>
|
||||
<input type="file" accept={acceptTypes} onChange={handleFileChange} className="hidden" />
|
||||
</label>
|
||||
) : (
|
||||
<div className="border border-border-subtle rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-bg-elevated border-b border-border-subtle">
|
||||
<span className="text-xs font-medium text-text-secondary">已选文件</span>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-accent-indigo flex-shrink-0" />
|
||||
) : uploadError ? (
|
||||
<AlertTriangle className="w-4 h-4 text-accent-coral flex-shrink-0" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4 text-accent-green flex-shrink-0" />
|
||||
)}
|
||||
<FileText className="w-4 h-4 text-accent-indigo flex-shrink-0" />
|
||||
<span className="flex-1 text-sm text-text-primary truncate">{file.name}</span>
|
||||
<span className="text-xs text-text-tertiary">{formatSize(file.size)}</span>
|
||||
{!isUploading && (
|
||||
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
|
||||
<XCircle className="w-4 h-4 text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isUploading && (
|
||||
<>
|
||||
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent-indigo rounded-full transition-all duration-300" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 ml-[30px] text-xs text-text-tertiary">上传中 {progress}%</p>
|
||||
</>
|
||||
)}
|
||||
{uploadError && <p className="mt-1 ml-[30px] text-xs text-accent-coral">{uploadError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!file || isUploading}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUploading ? <><Loader2 className="w-5 h-5 animate-spin" />上传中 {progress}%</> : <><Upload className="w-5 h-5" />{isScript ? '提交脚本' : '提交视频'}</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getDimensionLabel(key: string) {
|
||||
const labels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
|
||||
return labels[key] || key
|
||||
}
|
||||
|
||||
function AIResultDetailSection({ task }: { task: TaskData }) {
|
||||
if (!task.aiResult) return null
|
||||
const { dimensions, sellingPointMatches, briefMatchDetail, violations } = task.aiResult
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card rounded-2xl card-shadow">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-5 h-5 text-accent-indigo" />
|
||||
<span className="text-base font-semibold text-text-primary">AI 审核结果</span>
|
||||
</div>
|
||||
<span className={cn('text-xl font-bold', task.aiResult.score >= 85 ? 'text-accent-green' : task.aiResult.score >= 70 ? 'text-yellow-400' : 'text-accent-coral')}>
|
||||
{task.aiResult.score}分
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{dimensions && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
|
||||
const dim = dimensions[key]
|
||||
if (!dim) return null
|
||||
return (
|
||||
<div key={key} className={cn('p-3 rounded-xl border', dim.passed ? 'bg-accent-green/5 border-accent-green/20' : 'bg-accent-coral/5 border-accent-coral/20')}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-text-secondary">{getDimensionLabel(key)}</span>
|
||||
{dim.passed ? <CheckCircle className="w-4 h-4 text-accent-green" /> : <XCircle className="w-4 h-4 text-accent-coral" />}
|
||||
</div>
|
||||
<span className={cn('text-lg font-bold', dim.passed ? (dim.score >= 85 ? 'text-accent-green' : 'text-yellow-400') : 'text-accent-coral')}>{dim.score}</span>
|
||||
{dim.issue_count > 0 && <span className="text-xs text-text-tertiary ml-1">({dim.issue_count} 项问题)</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{violations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-accent-coral" /> 违规检测 ({violations.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{violations.map((v, idx) => (
|
||||
<div key={idx} className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-accent-coral/15 text-accent-coral">{v.type}</span>
|
||||
{v.dimension && <span className="text-xs text-text-tertiary">{getDimensionLabel(v.dimension)}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-text-primary">「{v.content}」</p>
|
||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Brief 匹配度详情 */}
|
||||
{briefMatchDetail && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-accent-indigo" /> Brief 匹配度分析
|
||||
</h4>
|
||||
<div className="p-3 bg-bg-elevated rounded-xl space-y-3">
|
||||
{/* 评分说明 */}
|
||||
<p className="text-sm text-text-secondary">{briefMatchDetail.explanation}</p>
|
||||
{/* 覆盖率进度条 */}
|
||||
{briefMatchDetail.total_points > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-text-tertiary">卖点覆盖率</span>
|
||||
<span className="text-text-primary font-medium">{briefMatchDetail.matched_points}/{briefMatchDetail.required_points > 0 ? briefMatchDetail.required_points : briefMatchDetail.total_points} 条</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bg-page rounded-full overflow-hidden">
|
||||
<div className={cn('h-full rounded-full transition-all', briefMatchDetail.coverage_score >= 80 ? 'bg-accent-green' : briefMatchDetail.coverage_score >= 50 ? 'bg-accent-amber' : 'bg-accent-coral')} style={{ width: `${briefMatchDetail.coverage_score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 亮点 */}
|
||||
{briefMatchDetail.highlights.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-accent-green font-medium mb-1">亮点</p>
|
||||
<div className="space-y-1">
|
||||
{briefMatchDetail.highlights.map((h, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-text-secondary">{h}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 问题点 */}
|
||||
{briefMatchDetail.issues.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-accent-coral font-medium mb-1">可改进</p>
|
||||
<div className="space-y-1">
|
||||
{briefMatchDetail.issues.map((issue, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-accent-coral flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-text-secondary">{issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 卖点匹配列表 */}
|
||||
{sellingPointMatches && sellingPointMatches.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-accent-green" /> 卖点匹配详情
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{sellingPointMatches.map((sp, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2.5 rounded-xl bg-bg-elevated">
|
||||
{sp.matched ? <CheckCircle className="w-4 h-4 text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 text-accent-coral flex-shrink-0 mt-0.5" />}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-primary">{sp.content}</span>
|
||||
<span className={cn('px-1.5 py-0.5 text-xs rounded',
|
||||
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||
'bg-bg-page text-text-tertiary'
|
||||
)}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
|
||||
</div>
|
||||
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewFeedbackCard({ review, type }: { review: { result: string; comment: string; reviewer: string; time: string }; type: 'agency' | 'brand' }) {
|
||||
const isApproved = review.result === 'approved'
|
||||
const title = type === 'agency' ? '代理商审核意见' : '品牌方终审意见'
|
||||
return (
|
||||
<div className={cn('bg-bg-card rounded-2xl card-shadow border', isApproved ? 'border-accent-green/30' : 'border-accent-coral/30')}>
|
||||
<div className="flex items-center gap-2 p-4 border-b border-border-subtle">
|
||||
{isApproved ? <CheckCircle className="w-5 h-5 text-accent-green" /> : <XCircle className="w-5 h-5 text-accent-coral" />}
|
||||
<span className="text-base font-semibold text-text-primary">{title}</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-text-primary">{review.reviewer}</span>
|
||||
<span className={cn('px-2 py-0.5 rounded text-xs font-semibold', isApproved ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-coral/15 text-accent-coral')}>
|
||||
{isApproved ? '通过' : '驳回'}
|
||||
</span>
|
||||
</div>
|
||||
{review.comment && <p className="text-sm text-text-secondary">{review.comment}</p>}
|
||||
<p className="text-xs text-text-tertiary mt-2">{review.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UploadView({ task, toast, briefData, onUploaded }: { task: TaskData; toast: ReturnType<typeof useToast>; briefData: typeof mockBriefData; onUploaded: () => void }) {
|
||||
const isScript = task.phase === 'script'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
{isScript && <AgencyBriefSection toast={toast} briefData={briefData} />}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-primary">{isScript ? '上传脚本' : '上传视频'}</h3>
|
||||
<p className="text-sm text-text-tertiary">{isScript ? '支持粘贴文本或上传文档' : '支持 MP4/MOV 格式,≤ 100MB'}</p>
|
||||
</div>
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-indigo/15 text-accent-indigo">待提交</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 flex flex-col items-center justify-center gap-5 rounded-2xl border-2 border-dashed transition-colors card-shadow bg-bg-card min-h-[400px] border-border-subtle hover:border-accent-indigo/50 cursor-pointer"
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
<div className="w-20 h-20 rounded-full bg-accent-indigo/15 flex items-center justify-center">
|
||||
<Upload className="w-10 h-10 text-accent-indigo" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-lg font-semibold text-text-primary">点击进入上传页面</p>
|
||||
<p className="text-sm text-text-tertiary">{isScript ? '支持 .doc、.docx、.txt 格式' : '支持 MP4/MOV 格式,≤ 100MB'}</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleUploadClick} className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold hover:opacity-90 transition-opacity">
|
||||
<Upload className="w-5 h-5" />
|
||||
{isScript ? '上传脚本文档' : '上传视频文件'}
|
||||
</button>
|
||||
</div>
|
||||
<FileUploadSection taskId={task.id} phase={task.phase} onUploaded={onUploaded} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -489,20 +791,20 @@ function AIReviewingView({ task }: { task: TaskData }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => void }) {
|
||||
function RejectionView({ task, onAppeal, onReupload }: { task: TaskData; onAppeal: () => void; onReupload: () => void }) {
|
||||
const getTitle = () => {
|
||||
switch (task.stage) {
|
||||
case 'ai_result': return 'AI 审核结果'
|
||||
case 'agency_rejected': return '代理商审核结果'
|
||||
case 'brand_rejected': return '品牌方审核结果'
|
||||
case 'agency_rejected': return '代理商审核驳回'
|
||||
case 'brand_rejected': return '品牌方审核驳回'
|
||||
default: return '审核结果'
|
||||
}
|
||||
}
|
||||
const getStatusText = () => {
|
||||
switch (task.stage) {
|
||||
case 'ai_result': return 'AI 检测到问题'
|
||||
case 'agency_rejected': return '代理商审核驳回'
|
||||
case 'brand_rejected': return '品牌方审核驳回'
|
||||
case 'ai_result': return 'AI 检测到问题,请修改后重新上传'
|
||||
case 'agency_rejected': return '代理商审核驳回,请根据意见修改'
|
||||
case 'brand_rejected': return '品牌方审核驳回,请根据意见修改'
|
||||
default: return '需要修改'
|
||||
}
|
||||
}
|
||||
@ -510,7 +812,7 @@ function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => voi
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
<ReviewProgressBar task={task} />
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex-1 flex flex-col">
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="flex items-center gap-3 pb-5 border-b border-border-subtle">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent-coral/15 flex items-center justify-center">
|
||||
<XCircle className="w-6 h-6 text-accent-coral" />
|
||||
@ -525,35 +827,19 @@ function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => voi
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{task.rejectionReason}</p>
|
||||
</div>
|
||||
)}
|
||||
{task.issues && task.issues.length > 0 && (
|
||||
<div className="py-4 flex flex-col gap-4 flex-1">
|
||||
<span className="text-sm font-semibold text-text-primary">发现 {task.issues.length} 处问题</span>
|
||||
<div className="flex flex-col gap-3">
|
||||
{task.issues.map((issue, index) => (
|
||||
<div key={index} className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('px-2 py-0.5 rounded text-xs font-semibold',
|
||||
issue.severity === 'error' ? 'bg-accent-coral/15 text-accent-coral' : 'bg-amber-500/15 text-amber-500'
|
||||
)}>
|
||||
{issue.severity === 'error' ? '违规' : '建议'}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text-primary">{issue.title}</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-text-secondary leading-relaxed">{issue.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border-subtle">
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<button type="button" onClick={onAppeal} className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium hover:bg-bg-page transition-colors">
|
||||
<MessageCircle className="w-[18px] h-[18px]" /> 申诉
|
||||
</button>
|
||||
<button type="button" className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold hover:bg-accent-green/90 transition-colors">
|
||||
<button type="button" onClick={onReupload} className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold hover:bg-accent-green/90 transition-colors">
|
||||
<Upload className="w-[18px] h-[18px]" /> 重新上传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{task.stage === 'agency_rejected' && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
|
||||
{task.stage === 'brand_rejected' && task.brandReview && <ReviewFeedbackCard review={task.brandReview} type="brand" />}
|
||||
{task.stage === 'brand_rejected' && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
|
||||
<AIResultDetailSection task={task} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -567,9 +853,14 @@ function WaitingReviewView({ task }: { task: TaskData }) {
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
<ReviewProgressBar task={task} />
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<FileText className="w-5 h-5 text-text-secondary" />
|
||||
<span className="text-base font-semibold text-text-primary">{task.phase === 'script' ? '脚本提交信息' : '视频提交信息'}</span>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-accent-indigo" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-lg font-semibold text-text-primary">{title}</span>
|
||||
<span className="text-sm text-text-secondary">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -600,28 +891,8 @@ function WaitingReviewView({ task }: { task: TaskData }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex-1">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-accent-indigo" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-lg font-semibold text-text-primary">{title}</span>
|
||||
<span className="text-sm text-text-secondary">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-accent-indigo/10 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-accent-indigo flex-shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-text-primary">温馨提示</span>
|
||||
<span className="text-[13px] text-text-secondary">
|
||||
{isAgency ? '代理商通常会在 1-2 个工作日内完成审核。' : '品牌方终审通常需要 1-3 个工作日。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isAgency && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
|
||||
<AIResultDetailSection task={task} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -631,30 +902,6 @@ function ApprovedView({ task }: { task: TaskData }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
<ReviewProgressBar task={task} />
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<FileText className="w-5 h-5 text-text-secondary" />
|
||||
<span className="text-base font-semibold text-text-primary">{task.phase === 'script' ? '脚本提交信息' : '视频提交信息'}</span>
|
||||
</div>
|
||||
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">提交时间</span><span className="text-sm text-text-primary">{task.submittedAt || '2026-02-01 10:30'}</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">AI审核</span><span className="text-sm text-accent-green font-medium">已通过</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">代理商审核</span><span className="text-sm text-accent-green font-medium">已通过</span></div>
|
||||
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">品牌方终审</span><span className="text-sm text-accent-green font-medium">已通过</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-accent-green" />
|
||||
<span className="text-lg font-semibold text-text-primary">品牌方审核通过</span>
|
||||
</div>
|
||||
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-green/15 text-accent-green">已通过</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{isVideoPhase ? '恭喜!视频已通过所有审核,可以发布了' : '脚本已通过品牌方终审,请继续上传视频'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent-green/15 flex items-center justify-center">
|
||||
@ -677,6 +924,9 @@ function ApprovedView({ task }: { task: TaskData }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{task.brandReview && <ReviewFeedbackCard review={task.brandReview} type="brand" />}
|
||||
{task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
|
||||
<AIResultDetailSection task={task} />
|
||||
{!isVideoPhase && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<button type="button" className="flex items-center gap-2 px-12 py-4 rounded-xl bg-accent-green text-white text-base font-semibold">
|
||||
@ -701,6 +951,7 @@ export default function TaskDetailPage() {
|
||||
const [briefData, setBriefData] = useState(mockBriefData)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showReupload, setShowReupload] = useState(false)
|
||||
|
||||
const loadTask = useCallback(async () => {
|
||||
if (USE_MOCK) {
|
||||
@ -728,7 +979,7 @@ export default function TaskDetailPage() {
|
||||
sellingPoints: (brief.selling_points || []).map((sp, i) => ({
|
||||
id: `sp-${i}`,
|
||||
content: sp.content,
|
||||
required: sp.required,
|
||||
priority: (sp.priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference',
|
||||
})),
|
||||
blacklistWords: (brief.blacklist_words || []).map((bw, i) => ({
|
||||
id: `bw-${i}`,
|
||||
@ -762,6 +1013,14 @@ export default function TaskDetailPage() {
|
||||
return () => { unsub1(); unsub2() }
|
||||
}, [subscribe, taskId, loadTask])
|
||||
|
||||
// AI 审核中时轮询(SSE 后备方案)
|
||||
useEffect(() => {
|
||||
if (!taskData || (taskData.stage !== 'ai_reviewing') || USE_MOCK) return
|
||||
const interval = setInterval(() => { loadTask() }, 5000)
|
||||
return () => clearInterval(interval)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskData?.stage, loadTask])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ResponsiveLayout role="creator">
|
||||
@ -793,12 +1052,27 @@ export default function TaskDetailPage() {
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
// 驳回状态下选择重新上传时,显示上传界面
|
||||
if (showReupload && (taskData.stage === 'ai_result' || taskData.stage === 'agency_rejected' || taskData.stage === 'brand_rejected')) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => setShowReupload(false)} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-elevated text-text-secondary text-sm hover:bg-bg-card transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" /> 返回审核详情
|
||||
</button>
|
||||
</div>
|
||||
{taskData.phase === 'script' && <AgencyBriefSection toast={toast} briefData={briefData} />}
|
||||
<FileUploadSection taskId={taskData.id} phase={taskData.phase} onUploaded={() => { setShowReupload(false); loadTask() }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
switch (taskData.stage) {
|
||||
case 'upload': return <UploadView task={taskData} toast={toast} briefData={briefData} />
|
||||
case 'upload': return <UploadView task={taskData} toast={toast} briefData={briefData} onUploaded={loadTask} />
|
||||
case 'ai_reviewing': return <AIReviewingView task={taskData} />
|
||||
case 'ai_result':
|
||||
case 'agency_rejected':
|
||||
case 'brand_rejected': return <RejectionView task={taskData} onAppeal={handleAppeal} />
|
||||
case 'brand_rejected': return <RejectionView task={taskData} onAppeal={handleAppeal} onReupload={() => setShowReupload(true)} />
|
||||
case 'agency_reviewing':
|
||||
case 'brand_reviewing': return <WaitingReviewView task={taskData} />
|
||||
case 'brand_approved': return <ApprovedView task={taskData} />
|
||||
|
||||
@ -16,9 +16,17 @@ import { Modal } from '@/components/ui/Modal'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
import { useSSE } from '@/contexts/SSEContext'
|
||||
import type { TaskResponse, AIReviewResult } from '@/types/task'
|
||||
import type { TaskResponse, AIReviewResult, ReviewDimensions, SellingPointMatchResult, BriefMatchDetail } from '@/types/task'
|
||||
import type { BriefResponse } from '@/types/brief'
|
||||
|
||||
// ========== 工具函数 ==========
|
||||
function getSellingPointPriority(sp: { priority?: string; required?: boolean }): 'core' | 'recommended' | 'reference' {
|
||||
if (sp.priority) return sp.priority as 'core' | 'recommended' | 'reference'
|
||||
if (sp.required === true) return 'core'
|
||||
if (sp.required === false) return 'recommended'
|
||||
return 'recommended'
|
||||
}
|
||||
|
||||
// ========== 类型 ==========
|
||||
type AgencyBriefFile = { id: string; name: string; size: string; uploadedAt: string; description?: string }
|
||||
|
||||
@ -29,8 +37,10 @@ type ScriptTaskUI = {
|
||||
scriptFile: string | null
|
||||
aiResult: null | {
|
||||
score: number
|
||||
violations: Array<{ type: string; content: string; suggestion: string }>
|
||||
complianceChecks: Array<{ item: string; passed: boolean; note?: string }>
|
||||
dimensions?: ReviewDimensions
|
||||
sellingPointMatches?: SellingPointMatchResult[]
|
||||
briefMatchDetail?: BriefMatchDetail
|
||||
violations: Array<{ type: string; content: string; suggestion: string; dimension?: string }>
|
||||
}
|
||||
agencyReview: null | { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
|
||||
brandReview: null | { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
|
||||
@ -38,7 +48,7 @@ type ScriptTaskUI = {
|
||||
|
||||
type BriefUI = {
|
||||
files: AgencyBriefFile[]
|
||||
sellingPoints: { id: string; content: string; required: boolean }[]
|
||||
sellingPoints: { id: string; content: string; priority: 'core' | 'recommended' | 'reference' }[]
|
||||
blacklistWords: { id: string; word: string; reason: string }[]
|
||||
}
|
||||
|
||||
@ -64,10 +74,10 @@ function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI {
|
||||
|
||||
const aiResult = task.script_ai_result ? {
|
||||
score: task.script_ai_result.score,
|
||||
violations: task.script_ai_result.violations.map(v => ({ type: v.type, content: v.content, suggestion: v.suggestion })),
|
||||
complianceChecks: task.script_ai_result.violations.map(v => ({
|
||||
item: v.type, passed: v.severity !== 'error' && v.severity !== 'warning', note: v.suggestion,
|
||||
})),
|
||||
dimensions: task.script_ai_result.dimensions,
|
||||
sellingPointMatches: task.script_ai_result.selling_point_matches,
|
||||
briefMatchDetail: task.script_ai_result.brief_match_detail,
|
||||
violations: task.script_ai_result.violations.map(v => ({ type: v.type, content: v.content, suggestion: v.suggestion, dimension: v.dimension })),
|
||||
} : null
|
||||
|
||||
const agencyReview = task.script_agency_status && task.script_agency_status !== 'pending' ? {
|
||||
@ -100,7 +110,7 @@ function mapBriefToUI(brief: BriefResponse): BriefUI {
|
||||
files: (brief.attachments || []).map((a, i) => ({
|
||||
id: a.id || `att-${i}`, name: a.name, size: a.size || '', uploadedAt: brief.updated_at || '',
|
||||
})),
|
||||
sellingPoints: (brief.selling_points || []).map((sp, i) => ({ id: `sp-${i}`, content: sp.content, required: sp.required })),
|
||||
sellingPoints: (brief.selling_points || []).map((sp, i) => ({ id: `sp-${i}`, content: sp.content, priority: getSellingPointPriority(sp) })),
|
||||
blacklistWords: (brief.blacklist_words || []).map((bw, i) => ({ id: `bw-${i}`, word: bw.word, reason: bw.reason })),
|
||||
}
|
||||
}
|
||||
@ -112,9 +122,9 @@ const mockBrief: BriefUI = {
|
||||
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02' },
|
||||
],
|
||||
sellingPoints: [
|
||||
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
|
||||
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
|
||||
{ id: 'sp3', content: '延展性好,易推开', required: false },
|
||||
{ id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const },
|
||||
{ id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const },
|
||||
{ id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const },
|
||||
],
|
||||
blacklistWords: [
|
||||
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
|
||||
@ -133,8 +143,9 @@ function AgencyBriefSection({ toast, briefData }: { toast: ReturnType<typeof use
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
|
||||
const handleDownload = (file: AgencyBriefFile) => { toast.info(`下载文件: ${file.name}`) }
|
||||
const requiredPoints = briefData.sellingPoints.filter(sp => sp.required)
|
||||
const optionalPoints = briefData.sellingPoints.filter(sp => !sp.required)
|
||||
const corePoints = briefData.sellingPoints.filter(sp => sp.priority === 'core')
|
||||
const recommendedPoints = briefData.sellingPoints.filter(sp => sp.priority === 'recommended')
|
||||
const referencePoints = briefData.sellingPoints.filter(sp => sp.priority === 'reference')
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -172,18 +183,26 @@ function AgencyBriefSection({ toast, briefData }: { toast: ReturnType<typeof use
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><Target size={14} className="text-accent-green" />卖点要求</h4>
|
||||
<div className="space-y-2">
|
||||
{requiredPoints.length > 0 && (
|
||||
{corePoints.length > 0 && (
|
||||
<div className="p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
|
||||
<p className="text-xs text-accent-coral font-medium mb-2">必选卖点(必须提及)</p>
|
||||
<div className="flex flex-wrap gap-2">{requiredPoints.map((sp) => (
|
||||
<p className="text-xs text-accent-coral font-medium mb-2">核心卖点(建议优先提及)</p>
|
||||
<div className="flex flex-wrap gap-2">{corePoints.map((sp) => (
|
||||
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded">{sp.content}</span>
|
||||
))}</div>
|
||||
</div>
|
||||
)}
|
||||
{optionalPoints.length > 0 && (
|
||||
{recommendedPoints.length > 0 && (
|
||||
<div className="p-3 bg-accent-amber/10 rounded-lg border border-accent-amber/30">
|
||||
<p className="text-xs text-accent-amber font-medium mb-2">推荐卖点(建议提及)</p>
|
||||
<div className="flex flex-wrap gap-2">{recommendedPoints.map((sp) => (
|
||||
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded">{sp.content}</span>
|
||||
))}</div>
|
||||
</div>
|
||||
)}
|
||||
{referencePoints.length > 0 && (
|
||||
<div className="p-3 bg-bg-elevated rounded-lg">
|
||||
<p className="text-xs text-text-tertiary font-medium mb-2">可选卖点</p>
|
||||
<div className="flex flex-wrap gap-2">{optionalPoints.map((sp) => (
|
||||
<p className="text-xs text-text-tertiary font-medium mb-2">参考信息</p>
|
||||
<div className="flex flex-wrap gap-2">{referencePoints.map((sp) => (
|
||||
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded">{sp.content}</span>
|
||||
))}</div>
|
||||
</div>
|
||||
@ -275,8 +294,8 @@ function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: ()
|
||||
<label className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
|
||||
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
|
||||
<p className="text-text-secondary mb-1">点击上传脚本文件</p>
|
||||
<p className="text-xs text-text-tertiary">支持 Word、PDF、TXT 格式</p>
|
||||
<input type="file" accept=".doc,.docx,.pdf,.txt" onChange={handleFileChange} className="hidden" />
|
||||
<p className="text-xs text-text-tertiary">支持 Word、PDF、TXT、Excel 格式</p>
|
||||
<input type="file" accept=".doc,.docx,.pdf,.txt,.xls,.xlsx" onChange={handleFileChange} className="hidden" />
|
||||
</label>
|
||||
) : (
|
||||
<div className="border border-border-subtle rounded-lg overflow-hidden">
|
||||
@ -355,8 +374,15 @@ function AIReviewingSection() {
|
||||
)
|
||||
}
|
||||
|
||||
function getDimensionLabel(key: string) {
|
||||
const labels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
|
||||
return labels[key] || key
|
||||
}
|
||||
|
||||
function AIResultSection({ task }: { task: ScriptTaskUI }) {
|
||||
if (!task.aiResult) return null
|
||||
const { dimensions, sellingPointMatches, briefMatchDetail, violations } = task.aiResult
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -366,32 +392,107 @@ function AIResultSection({ task }: { task: ScriptTaskUI }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{task.aiResult.violations.length > 0 && (
|
||||
{dimensions && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
|
||||
const dim = dimensions[key]
|
||||
if (!dim) return null
|
||||
return (
|
||||
<div key={key} className={`p-3 rounded-lg border ${dim.passed ? 'bg-accent-green/5 border-accent-green/20' : 'bg-accent-coral/5 border-accent-coral/20'}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-text-secondary">{getDimensionLabel(key)}</span>
|
||||
{dim.passed ? <CheckCircle size={14} className="text-accent-green" /> : <XCircle size={14} className="text-accent-coral" />}
|
||||
</div>
|
||||
<span className={`text-lg font-bold ${dim.passed ? (dim.score >= 85 ? 'text-accent-green' : 'text-yellow-400') : 'text-accent-coral'}`}>{dim.score}</span>
|
||||
{dim.issue_count > 0 && <span className="text-xs text-text-tertiary ml-1">({dim.issue_count} 项问题)</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{violations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><AlertTriangle size={14} className="text-orange-500" />违规检测 ({task.aiResult.violations.length})</h4>
|
||||
{task.aiResult.violations.map((v, idx) => (
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><AlertTriangle size={14} className="text-orange-500" />违规检测 ({violations.length})</h4>
|
||||
{violations.map((v, idx) => (
|
||||
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30 mb-2">
|
||||
<div className="flex items-center gap-2 mb-1"><WarningTag>{v.type}</WarningTag></div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<WarningTag>{v.type}</WarningTag>
|
||||
{v.dimension && <span className="text-xs text-text-tertiary">{getDimensionLabel(v.dimension)}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-text-primary">「{v.content}」</p>
|
||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2">合规检查</h4>
|
||||
<div className="space-y-2">
|
||||
{task.aiResult.complianceChecks.map((check, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{check.passed ? <CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />}
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-text-primary">{check.item}</span>
|
||||
{check.note && <p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>}
|
||||
{briefMatchDetail && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><Target size={14} className="text-accent-indigo" />Brief 匹配度分析</h4>
|
||||
<div className="p-3 bg-bg-elevated rounded-lg space-y-3">
|
||||
<p className="text-sm text-text-secondary">{briefMatchDetail.explanation}</p>
|
||||
{briefMatchDetail.total_points > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-text-tertiary">卖点覆盖率</span>
|
||||
<span className="text-text-primary font-medium">{briefMatchDetail.matched_points}/{briefMatchDetail.required_points > 0 ? briefMatchDetail.required_points : briefMatchDetail.total_points} 条</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bg-page rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${briefMatchDetail.coverage_score >= 80 ? 'bg-accent-green' : briefMatchDetail.coverage_score >= 50 ? 'bg-accent-amber' : 'bg-accent-coral'}`} style={{ width: `${briefMatchDetail.coverage_score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{briefMatchDetail.highlights.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-accent-green font-medium mb-1">亮点</p>
|
||||
<div className="space-y-1">
|
||||
{briefMatchDetail.highlights.map((h, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<CheckCircle size={14} className="text-accent-green flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-text-secondary">{h}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{briefMatchDetail.issues.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-accent-coral font-medium mb-1">可改进</p>
|
||||
<div className="space-y-1">
|
||||
{briefMatchDetail.issues.map((issue, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<AlertTriangle size={14} className="text-accent-coral flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-text-secondary">{issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sellingPointMatches && sellingPointMatches.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2"><Target size={14} className="text-accent-green" />卖点匹配</h4>
|
||||
<div className="space-y-2">
|
||||
{sellingPointMatches.map((sp, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||
{sp.matched ? <CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-primary">{sp.content}</span>
|
||||
<span className={`px-1.5 py-0.5 text-xs rounded ${
|
||||
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||
'bg-bg-page text-text-tertiary'
|
||||
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
|
||||
</div>
|
||||
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@ -484,6 +585,13 @@ export default function CreatorScriptPage() {
|
||||
return () => { unsub1(); unsub2() }
|
||||
}, [subscribe, taskId, loadTask])
|
||||
|
||||
// AI 审核中时轮询(SSE 的后备方案)
|
||||
useEffect(() => {
|
||||
if (task.scriptStatus !== 'ai_reviewing' || USE_MOCK) return
|
||||
const interval = setInterval(() => { loadTask() }, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [task.scriptStatus, loadTask])
|
||||
|
||||
const handleContinueToVideo = () => { router.push(`/creator/task/${params.id}/video`) }
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
|
||||
@ -362,6 +362,13 @@ export default function CreatorVideoPage() {
|
||||
return () => { unsub1(); unsub2() }
|
||||
}, [subscribe, taskId, loadTask])
|
||||
|
||||
// AI 审核中时轮询(SSE 的后备方案)
|
||||
useEffect(() => {
|
||||
if (task.videoStatus !== 'ai_reviewing' || USE_MOCK) return
|
||||
const interval = setInterval(() => { loadTask() }, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [task.videoStatus, loadTask])
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
const map: Record<string, string> = {
|
||||
pending_upload: '待上传视频', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
@ -20,6 +21,8 @@ import {
|
||||
MessageSquare
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
|
||||
interface NavItem {
|
||||
icon: React.ElementType
|
||||
@ -40,7 +43,7 @@ const agencyNavItems: NavItem[] = [
|
||||
{ icon: LayoutDashboard, label: '工作台', href: '/agency' },
|
||||
{ icon: Scan, label: '审核台', href: '/agency/review' },
|
||||
{ icon: MessageSquare, label: '申诉处理', href: '/agency/appeals' },
|
||||
{ icon: FileText, label: 'Brief 配置', href: '/agency/briefs' },
|
||||
{ icon: FileText, label: '任务配置', href: '/agency/briefs' },
|
||||
{ icon: Users, label: '达人管理', href: '/agency/creators' },
|
||||
{ icon: BarChart3, label: '数据报表', href: '/agency/reports' },
|
||||
{ icon: Bell, label: '消息中心', href: '/agency/messages' },
|
||||
@ -66,22 +69,47 @@ interface SidebarProps {
|
||||
|
||||
export function Sidebar({ role = 'creator', aiServiceError = false }: SidebarProps) {
|
||||
const pathname = usePathname() || ''
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
// 根据 aiServiceError 动态设置 AI 配置的徽章
|
||||
const getBrandNavItems = (): NavItem[] => {
|
||||
return brandNavItems.map(item => {
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
if (USE_MOCK) return
|
||||
try {
|
||||
const res = await api.getUnreadCount()
|
||||
setUnreadCount(res.count)
|
||||
} catch {
|
||||
// 忽略错误(未登录等)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnreadCount()
|
||||
const timer = setInterval(fetchUnreadCount, 30000) // 每 30 秒轮询
|
||||
return () => clearInterval(timer)
|
||||
}, [fetchUnreadCount])
|
||||
|
||||
// 消息中心路径
|
||||
const messagesHref = `/${role}/messages`
|
||||
|
||||
// 根据 aiServiceError 和 unreadCount 动态设置徽章
|
||||
const applyBadges = (items: NavItem[]): NavItem[] => {
|
||||
return items.map(item => {
|
||||
if (item.href === '/brand/ai-config' && aiServiceError) {
|
||||
return { ...item, badge: 'warning' as const }
|
||||
}
|
||||
if (item.href === messagesHref && unreadCount > 0) {
|
||||
return { ...item, badge: 'dot' as const }
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
const navItems = role === 'creator'
|
||||
const baseItems = role === 'creator'
|
||||
? creatorNavItems
|
||||
: role === 'agency'
|
||||
? agencyNavItems
|
||||
: getBrandNavItems()
|
||||
: brandNavItems
|
||||
|
||||
const navItems = applyBadges(baseItems)
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === `/${role}`) {
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { Button } from './Button'
|
||||
import { Modal } from './Modal'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
// 文件信息类型
|
||||
export interface FileInfo {
|
||||
@ -98,20 +99,29 @@ export function FileInfoCard({
|
||||
}) {
|
||||
const category = getFileCategory(file)
|
||||
|
||||
const handleDownload = () => {
|
||||
const handleDownload = async () => {
|
||||
if (onDownload) {
|
||||
onDownload()
|
||||
} else {
|
||||
// 默认下载行为
|
||||
const link = document.createElement('a')
|
||||
link.href = file.fileUrl
|
||||
link.download = file.fileName
|
||||
link.click()
|
||||
try {
|
||||
await api.downloadFile(file.fileUrl, file.fileName)
|
||||
} catch {
|
||||
// 回退到直接链接下载
|
||||
const link = document.createElement('a')
|
||||
link.href = file.fileUrl
|
||||
link.download = file.fileName
|
||||
link.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(file.fileUrl, '_blank')
|
||||
const handleOpenInNewTab = async () => {
|
||||
try {
|
||||
const blobUrl = await api.getPreviewUrl(file.fileUrl)
|
||||
window.open(blobUrl, '_blank')
|
||||
} catch {
|
||||
window.open(file.fileUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -300,15 +310,26 @@ export function DocumentPlaceholder({
|
||||
该文件格式暂不支持在线预览
|
||||
</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button variant="secondary" onClick={() => window.open(file.fileUrl, '_blank')}>
|
||||
<Button variant="secondary" onClick={async () => {
|
||||
try {
|
||||
const blobUrl = await api.getPreviewUrl(file.fileUrl)
|
||||
window.open(blobUrl, '_blank')
|
||||
} catch {
|
||||
window.open(file.fileUrl, '_blank')
|
||||
}
|
||||
}}>
|
||||
<ExternalLink size={16} />
|
||||
在新标签页打开
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
const link = document.createElement('a')
|
||||
link.href = file.fileUrl
|
||||
link.download = file.fileName
|
||||
link.click()
|
||||
<Button onClick={async () => {
|
||||
try {
|
||||
await api.downloadFile(file.fileUrl, file.fileName)
|
||||
} catch {
|
||||
const link = document.createElement('a')
|
||||
link.href = file.fileUrl
|
||||
link.download = file.fileName
|
||||
link.click()
|
||||
}
|
||||
}}>
|
||||
<Download size={16} />
|
||||
下载文件
|
||||
@ -380,15 +401,26 @@ export function FilePreviewModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => window.open(file.fileUrl, '_blank')}>
|
||||
<Button variant="secondary" onClick={async () => {
|
||||
try {
|
||||
const blobUrl = await api.getPreviewUrl(file.fileUrl)
|
||||
window.open(blobUrl, '_blank')
|
||||
} catch {
|
||||
window.open(file.fileUrl, '_blank')
|
||||
}
|
||||
}}>
|
||||
<ExternalLink size={16} />
|
||||
新标签页打开
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
const link = document.createElement('a')
|
||||
link.href = file.fileUrl
|
||||
link.download = file.fileName
|
||||
link.click()
|
||||
<Button onClick={async () => {
|
||||
try {
|
||||
await api.downloadFile(file.fileUrl, file.fileName)
|
||||
} catch {
|
||||
const link = document.createElement('a')
|
||||
link.href = file.fileUrl
|
||||
link.download = file.fileName
|
||||
link.click()
|
||||
}
|
||||
}}>
|
||||
<Download size={16} />
|
||||
下载
|
||||
|
||||
@ -75,6 +75,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (token && storedUser) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedUser)
|
||||
if (parsed.tenant_id) {
|
||||
api.setTenantId(parsed.tenant_id)
|
||||
}
|
||||
setUser(parsed)
|
||||
} catch {
|
||||
clearTokens()
|
||||
@ -109,6 +112,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// 真实 API 登录
|
||||
const response = await api.login(credentials)
|
||||
if (response.user.tenant_id) {
|
||||
api.setTenantId(response.user.tenant_id)
|
||||
}
|
||||
setUser(response.user)
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
|
||||
return { success: true }
|
||||
@ -138,6 +144,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// 真实 API 注册
|
||||
const response = await api.register(data)
|
||||
if (response.user.tenant_id) {
|
||||
api.setTenantId(response.user.tenant_id)
|
||||
}
|
||||
setUser(response.user)
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
|
||||
return { success: true }
|
||||
|
||||
@ -48,7 +48,7 @@ export function useSignedUrl(originalUrl: string | undefined | null) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const expireSeconds = 3600
|
||||
const url = await api.getSignedUrl(originalUrl, expireSeconds)
|
||||
const url = await api.getSignedUrl(originalUrl)
|
||||
if (mountedRef.current) {
|
||||
setSignedUrl(url)
|
||||
urlCache.set(originalUrl, {
|
||||
|
||||
@ -473,14 +473,44 @@ class ApiClient {
|
||||
/**
|
||||
* 获取私有桶文件的预签名访问 URL
|
||||
*/
|
||||
async getSignedUrl(url: string, expire: number = 3600, download: boolean = false): Promise<string> {
|
||||
async getSignedUrl(url: string): Promise<string> {
|
||||
const response = await this.client.get<{ signed_url: string; expire_seconds: number }>(
|
||||
'/upload/sign-url',
|
||||
{ params: { url, expire, download } }
|
||||
{ params: { url } }
|
||||
)
|
||||
return response.data.signed_url
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过后端代理获取文件预览 Blob URL(用于 iframe/img src)
|
||||
*/
|
||||
async getPreviewUrl(fileUrl: string): Promise<string> {
|
||||
const response = await this.client.get('/upload/preview', {
|
||||
params: { url: fileUrl },
|
||||
responseType: 'blob',
|
||||
})
|
||||
return URL.createObjectURL(response.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过后端代理下载文件(避免 TOS CORS 问题)
|
||||
*/
|
||||
async downloadFile(fileUrl: string, filename: string): Promise<void> {
|
||||
const response = await this.client.get('/upload/download', {
|
||||
params: { url: fileUrl },
|
||||
responseType: 'blob',
|
||||
})
|
||||
const blob = new Blob([response.data])
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
// ==================== 视频审核 ====================
|
||||
|
||||
/**
|
||||
@ -524,9 +554,9 @@ class ApiClient {
|
||||
/**
|
||||
* 查询任务列表
|
||||
*/
|
||||
async listTasks(page: number = 1, pageSize: number = 20, stage?: TaskStage): Promise<TaskListResponse> {
|
||||
async listTasks(page: number = 1, pageSize: number = 20, stage?: TaskStage, projectId?: string): Promise<TaskListResponse> {
|
||||
const response = await this.client.get<TaskListResponse>('/tasks', {
|
||||
params: { page, page_size: pageSize, stage },
|
||||
params: { page, page_size: pageSize, stage, project_id: projectId },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@ -677,6 +707,37 @@ class ApiClient {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理商更新 Brief(agency_attachments + selling_points + blacklist_words)
|
||||
*/
|
||||
async updateBriefByAgency(projectId: string, data: {
|
||||
agency_attachments?: Array<{ id?: string; name: string; url: string; size?: string }>
|
||||
selling_points?: Array<{ content: string; required: boolean }>
|
||||
blacklist_words?: Array<{ word: string; reason: string }>
|
||||
brand_tone?: string
|
||||
other_requirements?: string
|
||||
min_selling_points?: number | null
|
||||
}): Promise<BriefResponse> {
|
||||
const response = await this.client.patch<BriefResponse>(`/projects/${projectId}/brief/agency-attachments`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 解析 Brief 文档
|
||||
*/
|
||||
async parseBrief(projectId: string): Promise<{
|
||||
product_name: string
|
||||
target_audience: string
|
||||
content_requirements: string
|
||||
selling_points: Array<{ content: string; required: boolean }>
|
||||
blacklist_words: Array<{ word: string; reason: string }>
|
||||
}> {
|
||||
const response = await this.client.post(`/projects/${projectId}/brief/parse`, null, {
|
||||
timeout: 180000, // 3 分钟,文档下载 + AI 解析较慢
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== 组织关系 ====================
|
||||
|
||||
/**
|
||||
@ -841,6 +902,13 @@ class ApiClient {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除白名单
|
||||
*/
|
||||
async deleteWhitelistItem(id: string): Promise<void> {
|
||||
await this.client.delete(`/rules/whitelist/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询竞品列表
|
||||
*/
|
||||
|
||||
@ -12,7 +12,8 @@ export interface BriefAttachment {
|
||||
|
||||
export interface SellingPoint {
|
||||
content: string
|
||||
required: boolean
|
||||
priority?: 'core' | 'recommended' | 'reference'
|
||||
required?: boolean // 向后兼容旧格式
|
||||
}
|
||||
|
||||
export interface BlacklistWord {
|
||||
@ -27,6 +28,7 @@ export interface BriefResponse {
|
||||
file_url?: string | null
|
||||
file_name?: string | null
|
||||
selling_points?: SellingPoint[] | null
|
||||
min_selling_points?: number | null
|
||||
blacklist_words?: BlacklistWord[] | null
|
||||
competitors?: string[] | null
|
||||
brand_tone?: string | null
|
||||
|
||||
@ -43,14 +43,54 @@ export interface CreatorInfo {
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
// 审核维度评分
|
||||
export interface DimensionScore {
|
||||
score: number
|
||||
passed: boolean
|
||||
issue_count: number
|
||||
}
|
||||
|
||||
// 四维度审核结果
|
||||
export interface ReviewDimensions {
|
||||
legal: DimensionScore
|
||||
platform: DimensionScore
|
||||
brand_safety: DimensionScore
|
||||
brief_match: DimensionScore
|
||||
}
|
||||
|
||||
// 卖点匹配结果
|
||||
export interface SellingPointMatchResult {
|
||||
content: string
|
||||
priority: 'core' | 'recommended' | 'reference'
|
||||
matched: boolean
|
||||
evidence?: string
|
||||
}
|
||||
|
||||
// Brief 匹配度评分详情
|
||||
export interface BriefMatchDetail {
|
||||
total_points: number
|
||||
matched_points: number
|
||||
required_points: number
|
||||
coverage_score: number
|
||||
overall_score: number
|
||||
highlights: string[]
|
||||
issues: string[]
|
||||
explanation: string
|
||||
}
|
||||
|
||||
// AI 审核结果
|
||||
export interface AIReviewResult {
|
||||
score: number
|
||||
summary?: string
|
||||
dimensions?: ReviewDimensions
|
||||
selling_point_matches?: SellingPointMatchResult[]
|
||||
brief_match_detail?: BriefMatchDetail
|
||||
violations: Array<{
|
||||
type: string
|
||||
content: string
|
||||
severity: string
|
||||
suggestion: string
|
||||
dimension?: string
|
||||
timestamp?: number
|
||||
source?: string
|
||||
}>
|
||||
@ -59,7 +99,6 @@ export interface AIReviewResult {
|
||||
content: string
|
||||
suggestion: string
|
||||
}>
|
||||
summary?: string
|
||||
}
|
||||
|
||||
// 任务响应(对应后端 TaskResponse)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user