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:
Your Name 2026-02-11 19:11:54 +08:00
parent 0c59797d5b
commit 0ef7650c09
43 changed files with 3909 additions and 1316 deletions

View File

@ -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')

View File

@ -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_attachmentsselling_pointsblacklist_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)

View File

@ -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)

View File

@ -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

View File

@ -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],

View File

@ -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(...),

View 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)

View File

@ -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

View File

@ -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 表示降级为纯关键词检测)")
# ==================== 视频审核 ====================

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 = (

View File

@ -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

View File

@ -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]

View File

@ -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="视频效果好",

View File

@ -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()

View File

@ -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 && (

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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' :

View File

@ -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}>

View File

@ -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)

View File

@ -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)
}

View File

@ -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>

View File

@ -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,

View File

@ -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>
)}

View File

@ -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

View File

@ -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 : '未知错误'))

View File

@ -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('下载失败')
}
}

View File

@ -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} />

View File

@ -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"> WordPDFTXT </p>
<input type="file" accept=".doc,.docx,.pdf,.txt" onChange={handleFileChange} className="hidden" />
<p className="text-xs text-text-tertiary"> WordPDFTXTExcel </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 = () => {

View File

@ -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 审核完成',

View File

@ -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}`) {

View File

@ -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} />

View File

@ -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 }

View File

@ -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, {

View File

@ -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
}
/**
* Briefagency_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}`)
}
/**
*
*/

View File

@ -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

View File

@ -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