Compare commits

...

8 Commits

Author SHA1 Message Date
Your Name
0ef7650c09 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>
2026-02-11 19:11:54 +08:00
Your Name
0c59797d5b fix: 文件下载改用 fetch+blob 方式,避免浏览器显示乱码
用 fetch 获取文件内容后创建 Blob URL 触发下载,
不依赖服务端 Content-Disposition 头。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:39:30 +08:00
Your Name
9a0e7b356b fix: 文件下载乱码 — 签名 URL 增加 Content-Disposition: attachment
- generate_presigned_url 支持 download 参数,添加 response-content-disposition
- sign-url API 新增 download 查询参数
- 前端 getSignedUrl 支持 download 模式
- 下载时传 download=true,浏览器触发文件保存而非显示乱码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:18:28 +08:00
Your Name
0ab58b7e6e fix: 代理商平台显示 + Brief 下载预览功能
- 后端 TaskResponse.ProjectInfo 新增 platform 字段
- 修复代理商 6 个页面硬编码 platform='douyin' 的问题,改为读取实际值
- Brief 预览弹窗:占位符改为 iframe/img 实际展示文件内容
  - PDF 用 iframe 在线预览
  - 图片直接展示
  - 其他类型提示下载
- Brief 下载:改用 a 标签触发下载

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:15:03 +08:00
Your Name
4ca743e7b6 feat: 项目创建/分配代理商时发送消息通知给代理商
品牌方创建项目分配代理商、或后续追加代理商时,
被分配的代理商用户会收到"新项目分配"消息通知。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:02:08 +08:00
Your Name
4c9b2f1263 feat: Brief附件/项目平台/规则AI解析/消息中心修复 + 项目创建通知
- Brief 支持代理商附件上传 (迁移 007)
- 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息
- 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题
- 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护
- 项目创建时自动发送消息通知
- .gitignore 排除 backend/data/ 数据库文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:00:03 +08:00
Your Name
58aed5f201 feat: 私有桶签名 URL 支持 + TOS 凭证配置
后端 oss.py 新增 generate_presigned_url (TOS V4 Query String Auth),
upload.py 新增 GET /upload/sign-url 端点。前端 api.ts 添加 getSignedUrl
方法,新增 useSignedUrl hook 支持自动缓存和过期刷新。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:54:16 +08:00
Your Name
2f24dcfd34 feat: 规则冲突检测增强 — 后端接入 DB 规则 + 前端集成检查按钮
后端 validate_rules 端点改为 async,合并 DB active 平台规则与硬编码兜底规则,
新增 selling_points 字段支持和时长冲突检测。前端品牌方/代理商 Brief 页面
添加"检查规则冲突"按钮,支持选择平台后展示冲突详情弹窗。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:12:49 +08:00
71 changed files with 6296 additions and 1644 deletions

6
.gitignore vendored
View File

@ -42,6 +42,12 @@ Thumbs.db
.env.local
.env.*.local
# Database data
backend/data/
# Virtual environment
venv/
# Logs
*.log
npm-debug.log*

View File

@ -22,13 +22,15 @@ def upgrade() -> None:
# 创建枚举类型
platform_enum = postgresql.ENUM(
'douyin', 'xiaohongshu', 'bilibili', 'kuaishou',
name='platform_enum'
name='platform_enum',
create_type=False,
)
platform_enum.create(op.get_bind(), checkfirst=True)
task_status_enum = postgresql.ENUM(
'pending', 'processing', 'completed', 'failed', 'approved', 'rejected',
name='task_status_enum'
name='task_status_enum',
create_type=False,
)
task_status_enum.create(op.get_bind(), checkfirst=True)

View File

@ -17,38 +17,9 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"manual_tasks",
sa.Column("video_uploaded_at", sa.DateTime(timezone=True), nullable=True),
)
op.alter_column(
"manual_tasks",
"video_url",
existing_type=sa.String(length=2048),
nullable=True,
)
op.add_column(
"manual_tasks",
sa.Column("script_content", sa.Text(), nullable=True),
)
op.add_column(
"manual_tasks",
sa.Column("script_file_url", sa.String(length=2048), nullable=True),
)
op.add_column(
"manual_tasks",
sa.Column("script_uploaded_at", sa.DateTime(timezone=True), nullable=True),
)
# 原 manual_tasks 表已废弃,字段已合并到 003 的 tasks 表中
pass
def downgrade() -> None:
op.drop_column("manual_tasks", "script_uploaded_at")
op.drop_column("manual_tasks", "script_file_url")
op.drop_column("manual_tasks", "script_content")
op.alter_column(
"manual_tasks",
"video_url",
existing_type=sa.String(length=2048),
nullable=False,
)
op.drop_column("manual_tasks", "video_uploaded_at")
pass

View File

@ -22,7 +22,8 @@ def upgrade() -> None:
# 创建枚举类型
user_role_enum = postgresql.ENUM(
'brand', 'agency', 'creator',
name='user_role_enum'
name='user_role_enum',
create_type=False,
)
user_role_enum.create(op.get_bind(), checkfirst=True)
@ -30,10 +31,15 @@ def upgrade() -> None:
'script_upload', 'script_ai_review', 'script_agency_review', 'script_brand_review',
'video_upload', 'video_ai_review', 'video_agency_review', 'video_brand_review',
'completed', 'rejected',
name='task_stage_enum'
name='task_stage_enum',
create_type=False,
)
task_stage_enum.create(op.get_bind(), checkfirst=True)
# 扩展 task_status_enum添加 Task 模型需要的值
op.execute("ALTER TYPE task_status_enum ADD VALUE IF NOT EXISTS 'passed'")
op.execute("ALTER TYPE task_status_enum ADD VALUE IF NOT EXISTS 'force_passed'")
# 用户表
op.create_table(
'users',

View File

@ -0,0 +1,26 @@
"""添加 Brief 代理商附件字段
Revision ID: 007
Revises: 006
Create Date: 2026-02-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '007'
down_revision: Union[str, None] = '006'
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('agency_attachments', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('briefs', 'agency_attachments')

View File

@ -0,0 +1,26 @@
"""添加项目发布平台字段
Revision ID: 008
Revises: 007
Create Date: 2026-02-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '008'
down_revision: Union[str, None] = '007'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('projects', sa.Column('platform', sa.String(50), nullable=True))
def downgrade() -> None:
op.drop_column('projects', 'platform')

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
@ -16,10 +20,13 @@ from app.api.deps import get_current_user
from app.schemas.brief import (
BriefCreateRequest,
BriefUpdateRequest,
AgencyBriefUpdateRequest,
BriefResponse,
)
from app.services.auth import generate_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects/{project_id}/brief", tags=["Brief"])
@ -74,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,
@ -81,6 +89,7 @@ def _brief_to_response(brief: Brief) -> BriefResponse:
max_duration=brief.max_duration,
other_requirements=brief.other_requirements,
attachments=brief.attachments,
agency_attachments=brief.agency_attachments,
created_at=brief.created_at,
updated_at=brief.updated_at,
)
@ -137,6 +146,7 @@ async def create_brief(
max_duration=request.max_duration,
other_requirements=request.other_requirements,
attachments=request.attachments,
agency_attachments=request.agency_attachments,
)
db.add(brief)
await db.flush()
@ -180,3 +190,287 @@ async def update_brief(
await db.refresh(brief)
return _brief_to_response(brief)
@router.patch("/agency-attachments", response_model=BriefResponse)
async def update_brief_agency_attachments(
project_id: str,
request: AgencyBriefUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新 Brief 代理商配置(代理商操作)
代理商可更新agency_attachmentsselling_pointsblacklist_words
不能修改品牌方设置的核心 Brief 内容文件时长竞品等
"""
# 权限检查:代理商必须属于该项目
result = await db.execute(
select(Project)
.options(selectinload(Project.brand), selectinload(Project.agencies))
.where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
if current_user.role == UserRole.AGENCY:
agency_result = await db.execute(
select(Agency).where(Agency.user_id == current_user.id)
)
agency = agency_result.scalar_one_or_none()
if not agency or agency not in project.agencies:
raise HTTPException(status_code=403, detail="无权访问此项目")
elif current_user.role == UserRole.BRAND:
# 品牌方也可以更新代理商附件
brand_result = await db.execute(
select(Brand).where(Brand.user_id == current_user.id)
)
brand = brand_result.scalar_one_or_none()
if not brand or project.brand_id != brand.id:
raise HTTPException(status_code=403, detail="无权访问此项目")
else:
raise HTTPException(status_code=403, detail="无权修改代理商附件")
# 获取 Brief
brief_result = await db.execute(
select(Brief)
.options(selectinload(Brief.project))
.where(Brief.project_id == project_id)
)
brief = brief_result.scalar_one_or_none()
if not brief:
raise HTTPException(status_code=404, detail="Brief 不存在")
# 更新代理商可编辑的字段
update_fields = request.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(brief, field, value)
await db.flush()
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

@ -23,6 +23,7 @@ from app.schemas.project import (
AgencySummary,
)
from app.services.auth import generate_id
from app.services.message_service import create_message
router = APIRouter(prefix="/projects", tags=["项目"])
@ -46,6 +47,7 @@ async def _project_to_response(project: Project, db: AsyncSession) -> ProjectRes
id=project.id,
name=project.name,
description=project.description,
platform=project.platform,
brand_id=project.brand_id,
brand_name=project.brand.name if project.brand else None,
status=project.status,
@ -72,6 +74,7 @@ async def create_project(
brand_id=brand.id,
name=request.name,
description=request.description,
platform=request.platform,
start_date=request.start_date,
deadline=request.deadline,
status="active",
@ -79,7 +82,7 @@ async def create_project(
db.add(project)
await db.flush()
# 分配代理商
# 分配代理商(直接 INSERT 关联表,避免 async 懒加载问题)
if request.agency_ids:
for agency_id in request.agency_ids:
result = await db.execute(
@ -87,7 +90,12 @@ async def create_project(
)
agency = result.scalar_one_or_none()
if agency:
project.agencies.append(agency)
await db.execute(
project_agency_association.insert().values(
project_id=project.id,
agency_id=agency.id,
)
)
await db.flush()
await db.refresh(project)
@ -100,6 +108,40 @@ async def create_project(
)
project = result.scalar_one()
# 给品牌方用户发送项目创建成功消息
brand_user_result = await db.execute(
select(User).where(User.id == brand.user_id)
)
brand_user = brand_user_result.scalar_one_or_none()
if brand_user:
await create_message(
db=db,
user_id=brand_user.id,
type="system_notice",
title="项目创建成功",
content=f"您的项目「{project.name}」已创建成功",
related_project_id=project.id,
)
# 给被分配的代理商发送新项目通知
if project.agencies:
for agency in project.agencies:
agency_user_result = await db.execute(
select(User).where(User.id == agency.user_id)
)
agency_user = agency_user_result.scalar_one_or_none()
if agency_user:
await create_message(
db=db,
user_id=agency_user.id,
type="new_task",
title="新项目分配",
content=f"品牌方「{brand.name}」将您加入了项目「{project.name}",
related_project_id=project.id,
sender_name=brand.name,
)
await db.commit()
return await _project_to_response(project, db)
@ -248,6 +290,8 @@ async def update_project(
project.name = request.name
if request.description is not None:
project.description = request.description
if request.platform is not None:
project.platform = request.platform
if request.start_date is not None:
project.start_date = request.start_date
if request.deadline is not None:
@ -281,6 +325,7 @@ async def assign_agencies(
if project.brand_id != brand.id:
raise HTTPException(status_code=403, detail="无权操作此项目")
newly_assigned = []
for agency_id in request.agency_ids:
agency_result = await db.execute(
select(Agency).where(Agency.id == agency_id)
@ -288,10 +333,29 @@ async def assign_agencies(
agency = agency_result.scalar_one_or_none()
if agency and agency not in project.agencies:
project.agencies.append(agency)
newly_assigned.append(agency)
await db.flush()
await db.refresh(project)
# 给新分配的代理商发送通知
for agency in newly_assigned:
agency_user_result = await db.execute(
select(User).where(User.id == agency.user_id)
)
agency_user = agency_user_result.scalar_one_or_none()
if agency_user:
await create_message(
db=db,
user_id=agency_user.id,
type="new_task",
title="新项目分配",
content=f"品牌方「{brand.name}」将您加入了项目「{project.name}",
related_project_id=project.id,
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)
@ -455,30 +486,67 @@ async def get_platform_rules(platform: str) -> PlatformRuleResponse:
# ==================== 规则冲突检测 ====================
@router.post("/validate", response_model=RuleValidateResponse)
async def validate_rules(request: RuleValidateRequest) -> RuleValidateResponse:
"""检测 Brief 与平台规则冲突"""
async def validate_rules(
request: RuleValidateRequest,
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
db: AsyncSession = Depends(get_db),
) -> RuleValidateResponse:
"""检测 Brief 与平台规则冲突(合并 DB 规则 + 硬编码兜底)"""
conflicts = []
platform_rule = _platform_rules.get(request.platform)
if not platform_rule:
return RuleValidateResponse(conflicts=[])
# 1. 收集违禁词DB active 规则优先,硬编码兜底
db_rules = await get_active_platform_rules(
x_tenant_id, request.brand_id, request.platform, db
)
forbidden_words: set[str] = set()
min_seconds: Optional[int] = None
max_seconds: Optional[int] = None
# 检查 required_phrases 是否包含违禁词
required_phrases = request.brief_rules.get("required_phrases", [])
platform_forbidden = []
for rule in platform_rule.get("rules", []):
if db_rules:
forbidden_words.update(db_rules.get("forbidden_words", []))
duration = db_rules.get("duration") or {}
min_seconds = duration.get("min_seconds")
max_seconds = duration.get("max_seconds")
# 硬编码兜底
hardcoded = _platform_rules.get(request.platform, {})
for rule in hardcoded.get("rules", []):
if rule.get("type") == "forbidden_word":
platform_forbidden.extend(rule.get("words", []))
forbidden_words.update(rule.get("words", []))
elif rule.get("type") == "duration" and min_seconds is None:
if rule.get("min_seconds") is not None:
min_seconds = rule["min_seconds"]
if rule.get("max_seconds") is not None and max_seconds is None:
max_seconds = rule["max_seconds"]
for phrase in required_phrases:
for word in platform_forbidden:
if word in phrase:
# 2. 检查卖点/必选短语与违禁词冲突
phrases = list(request.brief_rules.get("required_phrases", []))
phrases += list(request.brief_rules.get("selling_points", []))
for phrase in phrases:
for word in forbidden_words:
if word in str(phrase):
conflicts.append(RuleConflict(
brief_rule=f"要求使用:{phrase}",
platform_rule=f"平台禁止:{word}",
suggestion=f"Brief 要求的 '{phrase}' 包含平台违禁词 '{word}',建议修改",
brief_rule=f"卖点包含{phrase}",
platform_rule=f"{request.platform} 禁止使用{word}",
suggestion=f"卖点 '{phrase}' 包含违禁词 '{word}',建议修改表述",
))
# 3. 检查时长冲突
brief_min = request.brief_rules.get("min_duration")
brief_max = request.brief_rules.get("max_duration")
if min_seconds and brief_max and brief_max < min_seconds:
conflicts.append(RuleConflict(
brief_rule=f"Brief 最长时长:{brief_max}",
platform_rule=f"{request.platform} 最短要求:{min_seconds}",
suggestion=f"Brief 最长 {brief_max}s 低于平台最短要求 {min_seconds}s视频可能不达标",
))
if max_seconds and brief_min and brief_min > max_seconds:
conflicts.append(RuleConflict(
brief_rule=f"Brief 最短时长:{brief_min}",
platform_rule=f"{request.platform} 最长限制:{max_seconds}",
suggestion=f"Brief 最短 {brief_min}s 超过平台最长限制 {max_seconds}s建议调整",
))
return RuleValidateResponse(conflicts=conflicts)
@ -521,22 +589,40 @@ async def parse_platform_rule_document(
"""
await _ensure_tenant_exists(x_tenant_id, db)
# 1. 下载并解析文档
# 1. 尝试提取文本;对图片型 PDF 走视觉解析
document_text = ""
image_b64_list: list[str] = []
try:
document_text = await DocumentParser.download_and_parse(
# 先检查是否为图片型 PDF
image_b64_list = await DocumentParser.download_and_get_images(
request.document_url, request.document_name,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
) or []
except Exception as e:
logger.error(f"文档解析失败: {e}")
raise HTTPException(status_code=400, detail=f"文档下载或解析失败: {e}")
logger.warning(f"图片 PDF 检测失败,回退文本模式: {e}")
if not document_text.strip():
raise HTTPException(status_code=400, detail="文档内容为空,无法解析")
if not image_b64_list:
# 非图片 PDF 或检测失败,走文本提取
try:
document_text = await DocumentParser.download_and_parse(
request.document_url, request.document_name,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"文档解析失败: {e}")
raise HTTPException(status_code=400, detail=f"文档下载或解析失败: {e}")
# 2. AI 解析
parsed_rules = await _ai_parse_platform_rules(x_tenant_id, request.platform, document_text, db)
if not document_text.strip():
raise HTTPException(status_code=400, detail="文档内容为空,无法解析")
# 2. AI 解析(图片模式 or 文本模式)
if image_b64_list:
parsed_rules = await _ai_parse_platform_rules_vision(
x_tenant_id, request.platform, image_b64_list, db,
)
else:
parsed_rules = await _ai_parse_platform_rules(x_tenant_id, request.platform, document_text, db)
# 3. 存入 DB (draft)
rule_id = f"pr-{uuid.uuid4().hex[:8]}"
@ -720,7 +806,8 @@ async def _ai_parse_platform_rules(
- duration: 视频时长要求如果文档未提及则为 null
- content_requirements: 内容上的硬性要求
- other_rules: 不属于以上分类的其他规则
- 如果某项没有提取到内容使用空数组或 null"""
- 如果某项没有提取到内容使用空数组或 null
- 重要JSON 字符串值中不要使用中文引号""使用单引号或直接省略"""
response = await ai_client.chat_completion(
messages=[{"role": "user", "content": prompt}],
@ -730,12 +817,7 @@ async def _ai_parse_platform_rules(
)
# 解析 AI 响应
content = response.content.strip()
if content.startswith("```"):
content = content.split("\n", 1)[1]
if content.endswith("```"):
content = content.rsplit("\n", 1)[0]
content = _extract_json_from_ai_response(response.content)
parsed = json.loads(content)
# 校验并补全字段
@ -747,14 +829,142 @@ async def _ai_parse_platform_rules(
"other_rules": parsed.get("other_rules", []),
}
except json.JSONDecodeError:
logger.warning("AI 返回内容非 JSON降级为空规则")
except json.JSONDecodeError as e:
logger.warning(f"AI 返回内容非 JSON降级为空规则: {e}")
return _empty_parsed_rules()
except Exception as e:
logger.error(f"AI 解析平台规则失败: {e}")
return _empty_parsed_rules()
async def _ai_parse_platform_rules_vision(
tenant_id: str,
platform: str,
image_b64_list: list[str],
db: AsyncSession,
) -> dict:
"""
使用 AI 视觉模型从 PDF 页面图片中提取结构化平台规则
用于扫描件/截图型 PDF
"""
try:
ai_client = await AIServiceFactory.get_client(tenant_id, db)
if not ai_client:
logger.warning(f"租户 {tenant_id} 未配置 AI 服务,返回空规则")
return _empty_parsed_rules()
config = await AIServiceFactory.get_config(tenant_id, db)
if not config:
return _empty_parsed_rules()
vision_model = config.models.get("vision", config.models.get("text", "gpt-4o"))
# 构建多模态消息
content: list[dict] = [
{
"type": "text",
"text": f"""你是平台广告合规规则分析专家。以下是 {platform} 平台规则文档的页面截图。
请仔细阅读所有页面从中提取结构化规则
请以 JSON 格式返回不要包含其他内容
{{
"forbidden_words": ["违禁词1", "违禁词2"],
"restricted_words": [{{"word": "xx", "condition": "使用条件", "suggestion": "替换建议"}}],
"duration": {{"min_seconds": 7, "max_seconds": null}},
"content_requirements": ["必须展示产品正面", "需要口播品牌名"],
"other_rules": [{{"rule": "规则名称", "description": "详细说明"}}]
}}
注意
- forbidden_words: 明确禁止使用的词语
- restricted_words: 有条件限制的词语
- duration: 视频时长要求如果文档未提及则为 null
- content_requirements: 内容上的硬性要求
- other_rules: 不属于以上分类的其他规则
- 如果某项没有提取到内容使用空数组或 null
- 重要JSON 字符串值中不要使用中文引号\u201c\u201d使用单引号或直接省略""",
}
]
for b64 in image_b64_list:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
response = await ai_client.chat_completion(
messages=[{"role": "user", "content": content}],
model=vision_model,
temperature=0.2,
max_tokens=3000,
)
# 解析 AI 响应
resp_content = _extract_json_from_ai_response(response.content)
parsed = json.loads(resp_content)
return {
"forbidden_words": parsed.get("forbidden_words", []),
"restricted_words": parsed.get("restricted_words", []),
"duration": parsed.get("duration"),
"content_requirements": parsed.get("content_requirements", []),
"other_rules": parsed.get("other_rules", []),
}
except json.JSONDecodeError as e:
logger.warning(f"AI 视觉解析返回内容非 JSON降级为空规则: {e}")
return _empty_parsed_rules()
except Exception as e:
logger.error(f"AI 视觉解析平台规则失败: {e}")
return _empty_parsed_rules()
def _extract_json_from_ai_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()
return _sanitize_json_string(text)
def _sanitize_json_string(text: str) -> str:
"""
清理 AI 返回的 JSON 文本中的中文引号等特殊字符
中文引号 "" JSON 字符串值内会破坏解析
"""
import re
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)
def _empty_parsed_rules() -> dict:
"""返回空的解析规则结构"""
return {
@ -833,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(
@ -68,6 +411,7 @@ def _task_to_response(task: Task) -> TaskResponse:
id=task.project.id,
name=task.project.name,
brand_name=task.project.brand.name if task.project.brand else None,
platform=task.project.platform,
),
agency=AgencyInfo(
id=task.agency.id,
@ -174,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)
@ -210,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),
):
@ -242,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(
@ -254,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(
@ -394,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],
@ -409,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)
@ -457,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],
@ -472,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)
@ -615,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)
@ -755,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)
@ -803,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,12 +1,13 @@
"""
文件上传 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form, status
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.services.oss import generate_upload_policy, get_file_url
from app.services.oss import generate_upload_policy, get_file_url, generate_presigned_url
from app.config import settings
from app.models.user import User
from app.api.deps import get_current_user
@ -123,3 +124,220 @@ async def file_uploaded(
file_size=request.file_size,
file_type=request.file_type,
)
class SignedUrlResponse(BaseModel):
"""签名 URL 响应"""
signed_url: str
expire_seconds: int
@router.get("/sign-url", response_model=SignedUrlResponse)
async def get_signed_url(
url: str = Query(..., description="文件的原始 URL 或 file_key"),
expire: int = Query(3600, ge=60, le=43200, description="有效期默认1小时最长12小时"),
current_user: User = Depends(get_current_user),
):
"""
获取私有桶文件的预签名访问 URL
前端在展示/下载文件前调用此接口获取带签名的临时访问链接
支持传入完整 URL file_key
"""
from app.services.oss import parse_file_key_from_url
# 如果传入的是完整 URL先解析出 file_key
file_key = url
if url.startswith("http"):
file_key = parse_file_key_from_url(url)
if not file_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的文件路径",
)
try:
signed_url = generate_presigned_url(file_key, expire_seconds=expire)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
return SignedUrlResponse(
signed_url=signed_url,
expire_seconds=expire,
)
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(...),
file_type: str = Form("general"),
current_user: User = Depends(get_current_user),
):
"""
后端代理上传用于本地开发 / 浏览器无法直连 TOS 的场景
前端把文件 POST 到此接口后端使用 TOS SDK 上传到对象存储
"""
import io
import tos as tos_sdk
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
raise HTTPException(status_code=500, detail="TOS 配置未设置")
now = datetime.now()
base_dir = f"uploads/{now.year}/{now.month:02d}"
type_dirs = {"script": "scripts", "video": "videos", "image": "images"}
sub_dir = type_dirs.get(file_type, "files")
file_key = f"{base_dir}/{sub_dir}/{int(now.timestamp())}_{file.filename}"
content = await file.read()
content_type = file.content_type or "application/octet-stream"
region = settings.TOS_REGION
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
try:
client = tos_sdk.TosClientV2(
ak=settings.TOS_ACCESS_KEY_ID,
sk=settings.TOS_SECRET_ACCESS_KEY,
endpoint=f"https://{endpoint}",
region=region,
)
client.put_object(
bucket=settings.TOS_BUCKET_NAME,
key=file_key,
content=io.BytesIO(content),
content_type=content_type,
)
except Exception as e:
raise HTTPException(
status_code=502,
detail=f"TOS 上传失败: {str(e)[:200]}",
)
url = get_file_url(file_key)
return FileUploadedResponse(
url=url,
file_key=file_key,
file_name=file.filename or "unknown",
file_size=len(content),
file_type=file_type,
)

View File

@ -54,8 +54,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
app.add_middleware(SecurityHeadersMiddleware)
# Rate limiting
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
# Rate limiting (仅生产环境启用)
if _is_production:
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
# 注册路由
app.include_router(health.router, prefix="/api/v1")

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)
@ -49,10 +52,14 @@ class Brief(Base, TimestampMixin):
# 其他要求(自由文本)
other_requirements: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 附件文档(代理商上传的参考资料)
# 附件文档(品牌方上传的参考资料)
# [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...]
attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
# 代理商附件(代理商上传的补充资料,与品牌方 attachments 分开存储)
# [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...]
agency_attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
# 关联
project: Mapped["Project"] = relationship("Project", back_populates="brief")

View File

@ -45,6 +45,9 @@ class Project(Base, TimestampMixin):
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
deadline: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# 发布平台 (douyin/xiaohongshu/bilibili/kuaishou 等)
platform: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
# 状态
status: Mapped[str] = mapped_column(
String(20),

View File

@ -47,7 +47,7 @@ class ReviewTask(Base, TimestampMixin):
# 视频信息
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
platform: Mapped[Platform] = mapped_column(
SQLEnum(Platform, name="platform_enum"),
SQLEnum(Platform, name="platform_enum", values_callable=lambda x: [e.value for e in x]),
nullable=False,
)
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
@ -55,7 +55,7 @@ class ReviewTask(Base, TimestampMixin):
# 审核状态
status: Mapped[TaskStatus] = mapped_column(
SQLEnum(TaskStatus, name="task_status_enum"),
SQLEnum(TaskStatus, name="task_status_enum", values_callable=lambda x: [e.value for e in x]),
default=TaskStatus.PENDING,
nullable=False,
index=True,

View File

@ -70,7 +70,7 @@ class Task(Base, TimestampMixin):
# 当前阶段
stage: Mapped[TaskStage] = mapped_column(
SQLEnum(TaskStage, name="task_stage_enum"),
SQLEnum(TaskStage, name="task_stage_enum", values_callable=lambda x: [e.value for e in x]),
default=TaskStage.SCRIPT_UPLOAD,
nullable=False,
index=True,
@ -88,7 +88,7 @@ class Task(Base, TimestampMixin):
# 脚本代理商审核
script_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
SQLEnum(TaskStatus, name="task_status_enum"),
SQLEnum(TaskStatus, name="task_status_enum", values_callable=lambda x: [e.value for e in x]),
nullable=True,
)
script_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@ -97,7 +97,7 @@ class Task(Base, TimestampMixin):
# 脚本品牌方终审
script_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]),
nullable=True,
)
script_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@ -118,7 +118,7 @@ class Task(Base, TimestampMixin):
# 视频代理商审核
video_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]),
nullable=True,
)
video_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@ -127,7 +127,7 @@ class Task(Base, TimestampMixin):
# 视频品牌方终审
video_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]),
nullable=True,
)
video_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

View File

@ -37,7 +37,7 @@ class User(Base, TimestampMixin):
# 角色
role: Mapped[UserRole] = mapped_column(
SQLEnum(UserRole, name="user_role_enum"),
SQLEnum(UserRole, name="user_role_enum", values_callable=lambda x: [e.value for e in x]),
nullable=False,
index=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
@ -20,6 +25,7 @@ class BriefCreateRequest(BaseModel):
max_duration: Optional[int] = None
other_requirements: Optional[str] = None
attachments: Optional[List[dict]] = None
agency_attachments: Optional[List[dict]] = None
class BriefUpdateRequest(BaseModel):
@ -34,6 +40,17 @@ class BriefUpdateRequest(BaseModel):
max_duration: Optional[int] = None
other_requirements: Optional[str] = None
attachments: Optional[List[dict]] = None
agency_attachments: Optional[List[dict]] = None
class AgencyBriefUpdateRequest(BaseModel):
"""代理商更新 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
# ===== 响应 =====
@ -46,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
@ -53,6 +71,7 @@ class BriefResponse(BaseModel):
max_duration: Optional[int] = None
other_requirements: Optional[str] = None
attachments: Optional[List[dict]] = None
agency_attachments: Optional[List[dict]] = None
created_at: datetime
updated_at: datetime

View File

@ -12,6 +12,7 @@ class ProjectCreateRequest(BaseModel):
"""创建项目请求(品牌方操作)"""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
platform: Optional[str] = None
start_date: Optional[datetime] = None
deadline: Optional[datetime] = None
agency_ids: Optional[List[str]] = None # 分配的代理商 ID 列表
@ -21,6 +22,7 @@ class ProjectUpdateRequest(BaseModel):
"""更新项目请求"""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
platform: Optional[str] = None
start_date: Optional[datetime] = None
deadline: Optional[datetime] = None
status: Optional[str] = Field(None, pattern="^(active|completed|archived)$")
@ -45,6 +47,7 @@ class ProjectResponse(BaseModel):
id: str
name: str
description: Optional[str] = None
platform: Optional[str] = None
brand_id: str
brand_name: Optional[str] = None
status: str

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

@ -87,6 +87,7 @@ class ProjectInfo(BaseModel):
id: str
name: str
brand_name: Optional[str] = None
platform: Optional[str] = None
class TaskResponse(BaseModel):

View File

@ -48,9 +48,12 @@ class OpenAICompatibleClient:
base_url: str,
api_key: str,
provider: str = "openai",
timeout: float = 60.0,
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:
"""从文档中提取纯文本"""
@ -17,6 +21,9 @@ class DocumentParser:
"""
下载文档并解析为纯文本
优先使用 TOS SDK 直接下载私有桶无需签名
回退到 HTTP 预签名 URL 下载
Args:
document_url: 文档 URL (TOS)
document_name: 原始文件名用于判断格式
@ -24,23 +31,130 @@ class DocumentParser:
Returns:
提取的纯文本
"""
# 下载到临时文件
tmp_path: Optional[str] = None
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.get(document_url)
resp.raise_for_status()
ext = document_name.rsplit(".", 1)[-1].lower() if "." in document_name else ""
# 优先用 TOS SDK 直接下载(后端有 AK/SK无需签名 URL
content = await DocumentParser._download_via_tos_sdk(document_url)
if content is None:
# 回退:生成预签名 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(resp.content)
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]]:
"""
下载文档并提取嵌入的图片返回 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 not in ("pdf", "doc", "docx", "xls", "xlsx"):
return None
tmp_path: Optional[str] = None
try:
file_content = await DocumentParser._download_via_tos_sdk(document_url)
if file_content is None:
file_content = await DocumentParser._download_via_signed_url(document_url)
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
tmp.write(file_content)
tmp_path = tmp.name
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):
os.unlink(tmp_path)
@staticmethod
async def _download_via_tos_sdk(document_url: str) -> Optional[bytes]:
"""通过 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:
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
return await asyncio.to_thread(_sync_download)
@staticmethod
async def _download_via_signed_url(document_url: str) -> bytes:
"""生成预签名 URL 后通过 HTTP 下载"""
from app.services.oss import generate_presigned_url, parse_file_key_from_url
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
def parse_file(file_path: str, file_name: str) -> str:
"""
@ -68,16 +182,73 @@ class DocumentParser:
@staticmethod
def _parse_pdf(path: str) -> str:
"""pdfplumber 提取 PDF 文本"""
import pdfplumber
"""PyMuPDF 提取 PDF 文本,回退 pdfplumber"""
import fitz
texts = []
with pdfplumber.open(path) as pdf:
for page in pdf.pages:
text = page.extract_text()
if text:
texts.append(text)
return "\n".join(texts)
doc = fitz.open(path)
for page in doc:
text = page.get_text()
if text and text.strip():
texts.append(text.strip())
doc.close()
result = "\n".join(texts)
# 如果 PyMuPDF 提取文本太少,回退 pdfplumber
if len(result.strip()) < 100:
try:
import pdfplumber
texts2 = []
with pdfplumber.open(path) as pdf:
for page in pdf.pages:
text = page.extract_text()
if text:
texts2.append(text)
fallback = "\n".join(texts2)
if len(fallback.strip()) > len(result.strip()):
result = fallback
except Exception:
pass
return result
@staticmethod
def pdf_to_images_base64(path: str, max_pages: int = 5, dpi: int = 150) -> list[str]:
"""
PDF 页面渲染为图片并返回 base64 编码列表
用于处理扫描件/图片型 PDF
"""
import fitz
import base64
images = []
doc = fitz.open(path)
for i, page in enumerate(doc):
if i >= max_pages:
break
zoom = dpi / 72
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
img_bytes = pix.tobytes("png")
b64 = base64.b64encode(img_bytes).decode()
images.append(b64)
doc.close()
return images
@staticmethod
def is_image_pdf(path: str) -> bool:
"""判断 PDF 是否为扫描件/图片型(文本内容极少)"""
import fitz
doc = fitz.open(path)
total_text = ""
for page in doc:
total_text += page.get_text()
doc.close()
# 去掉页码等噪音后,有效文字少于 200 字符视为图片 PDF
cleaned = "".join(c for c in total_text if c.strip())
return len(cleaned) < 200
@staticmethod
def _parse_docx(path: str) -> str:
@ -117,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

@ -139,6 +139,97 @@ def get_file_url(file_key: str) -> str:
return f"{host}/{file_key}"
def generate_presigned_url(
file_key: str,
expire_seconds: int = 3600,
) -> str:
"""
为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth)
签名流程:
1. 构建 CanonicalRequest
2. 构建 StringToSign
3. 用派生密钥签名
4. 拼接查询参数
Args:
file_key: 文件在 TOS 中的 key
expire_seconds: URL 有效期默认 1 小时
Returns:
预签名 URL
"""
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
raise ValueError("TOS 配置未设置")
from urllib.parse import quote
region = settings.TOS_REGION
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
bucket = settings.TOS_BUCKET_NAME
host = f"{bucket}.{endpoint}"
now_utc = datetime.now(timezone.utc)
date_stamp = now_utc.strftime("%Y%m%d")
tos_date = now_utc.strftime("%Y%m%dT%H%M%SZ")
credential = f"{settings.TOS_ACCESS_KEY_ID}/{date_stamp}/{region}/tos/request"
# 对 file_key 中的路径段分别编码
encoded_key = "/".join(quote(seg, safe="") for seg in file_key.split("/"))
# 查询参数(按字母序排列)
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 = (
f"GET\n"
f"/{encoded_key}\n"
f"{query_params}\n"
f"host:{host}\n"
f"\n"
f"host\n"
f"UNSIGNED-PAYLOAD"
)
# StringToSign
canonical_request_hash = hashlib.sha256(canonical_request.encode()).hexdigest()
string_to_sign = (
f"TOS4-HMAC-SHA256\n"
f"{tos_date}\n"
f"{date_stamp}/{region}/tos/request\n"
f"{canonical_request_hash}"
)
# 派生签名密钥
k_date = hmac.new(
f"TOS4{settings.TOS_SECRET_ACCESS_KEY}".encode(),
date_stamp.encode(),
hashlib.sha256,
).digest()
k_region = hmac.new(k_date, region.encode(), hashlib.sha256).digest()
k_service = hmac.new(k_region, b"tos", hashlib.sha256).digest()
k_signing = hmac.new(k_service, b"request", hashlib.sha256).digest()
# 计算签名
signature = hmac.new(
k_signing,
string_to_sign.encode(),
hashlib.sha256,
).hexdigest()
return (
f"https://{host}/{encoded_key}"
f"?{query_params}"
f"&X-Tos-Signature={signature}"
)
def parse_file_key_from_url(url: str) -> str:
"""
从完整 URL 解析出文件 key

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

@ -9,6 +9,8 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-miaosi}
ports:
- "5432:5432"
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
@ -21,6 +23,8 @@ services:
redis:
image: redis:7-alpine
container_name: miaosi-redis
ports:
- "6379:6379"
volumes:
- ./data/redis:/data
healthcheck:
@ -82,7 +86,8 @@ services:
depends_on:
redis:
condition: service_healthy
celery-worker: {}
celery-worker:
condition: service_started
command: celery -A app.celery_app beat -l info
# Next.js 前端

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

@ -165,6 +165,7 @@ async def seed_data() -> None:
brand_id=BRAND_ID,
name="2026春季新品推广",
description="春季新品防晒霜推广活动,面向 18-35 岁女性用户,重点投放抖音和小红书平台",
platform="douyin",
start_date=NOW,
deadline=NOW + timedelta(days=30),
status="active",
@ -188,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": "绝对化用语"},
@ -199,6 +201,7 @@ async def seed_data() -> None:
],
competitors=["安耐晒", "怡思丁", "薇诺娜"],
brand_tone="年轻、活力、专业、可信赖",
min_selling_points=2,
min_duration=30,
max_duration=60,
other_requirements="请在视频中展示产品实际使用效果,包含户外场景拍摄",
@ -235,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),
@ -257,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,
@ -282,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="通过",
@ -297,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

@ -25,7 +25,7 @@ alembic upgrade head
# 填充种子数据
echo "填充种子数据..."
python -m scripts.seed
python3 -m scripts.seed
echo ""
echo "=== 基础服务已启动 ==="

View File

@ -345,7 +345,7 @@ class TestRuleConflictDetection:
@pytest.mark.asyncio
async def test_detect_brief_platform_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""检测 Brief 与平台规则冲突"""
"""检测 Brief 与平台规则冲突required_phrases"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
@ -353,7 +353,7 @@ class TestRuleConflictDetection:
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"required_phrases": ["绝对有效"], # 可能违反平台规则
"required_phrases": ["绝对有效"],
}
}
)
@ -386,6 +386,190 @@ class TestRuleConflictDetection:
assert "platform_rule" in conflict
assert "suggestion" in conflict
@pytest.mark.asyncio
async def test_selling_points_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""selling_points 字段也参与冲突检测"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
json={
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"selling_points": ["100%纯天然成分", "绝对安全"],
}
}
)
data = response.json()
assert len(data["conflicts"]) >= 2 # "100%" 和 "绝对" 都命中
@pytest.mark.asyncio
async def test_no_conflict_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""无冲突时返回空列表"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
json={
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"selling_points": ["温和护肤", "适合敏感肌"],
}
}
)
data = response.json()
assert data["conflicts"] == []
@pytest.mark.asyncio
async def test_duration_conflict_brief_max_below_platform_min(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""Brief 最长时长低于平台最短要求"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
json={
"brand_id": brand_id,
"platform": "douyin", # 硬编码 min_seconds=7
"brief_rules": {
"max_duration": 5,
}
}
)
data = response.json()
assert len(data["conflicts"]) >= 1
assert any("时长" in c["brief_rule"] for c in data["conflicts"])
@pytest.mark.asyncio
async def test_db_rules_participate_in_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""DB 中 active 的规则参与冲突检测"""
headers = {"X-Tenant-ID": tenant_id}
# 创建并确认一条包含自定义违禁词的 DB 平台规则
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
rule_id = create_resp.json()["id"]
custom_rules = {
"forbidden_words": ["自定义违禁词ABC"],
"restricted_words": [],
"duration": {"min_seconds": 15, "max_seconds": 120},
"content_requirements": [],
"other_rules": [],
}
await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers=headers,
json={"parsed_rules": custom_rules},
)
# 验证 DB 违禁词参与检测
response = await client.post(
"/api/v1/rules/validate",
headers=headers,
json={
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"selling_points": ["这个自定义违禁词ABC很好"],
}
}
)
data = response.json()
assert len(data["conflicts"]) >= 1
assert any("自定义违禁词ABC" in c["suggestion"] for c in data["conflicts"])
@pytest.mark.asyncio
async def test_db_duration_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""DB 规则中的时长限制参与检测"""
headers = {"X-Tenant-ID": tenant_id}
# 创建 DB 规则max_seconds=60
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
rule_id = create_resp.json()["id"]
custom_rules = {
"forbidden_words": [],
"restricted_words": [],
"duration": {"min_seconds": 10, "max_seconds": 60},
"content_requirements": [],
"other_rules": [],
}
await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers=headers,
json={"parsed_rules": custom_rules},
)
# Brief 最短时长 90s > 平台最长 60s → 冲突
response = await client.post(
"/api/v1/rules/validate",
headers=headers,
json={
"brand_id": brand_id,
"platform": "xiaohongshu",
"brief_rules": {
"min_duration": 90,
}
}
)
data = response.json()
assert len(data["conflicts"]) >= 1
assert any("最长限制" in c["platform_rule"] for c in data["conflicts"])
@pytest.mark.asyncio
async def test_db_and_hardcoded_rules_merge(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""DB 规则与硬编码规则合并检测"""
headers = {"X-Tenant-ID": tenant_id}
# DB 规则只包含自定义违禁词
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
rule_id = create_resp.json()["id"]
await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers=headers,
json={"parsed_rules": {
"forbidden_words": ["DB专属词"],
"restricted_words": [],
"duration": None,
"content_requirements": [],
"other_rules": [],
}},
)
# selling_points 同时包含 DB 违禁词和硬编码违禁词
response = await client.post(
"/api/v1/rules/validate",
headers=headers,
json={
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"selling_points": ["这是DB专属词内容", "最好的选择"],
}
}
)
data = response.json()
# 应同时检出 DB 违禁词和硬编码违禁词
suggestions = [c["suggestion"] for c in data["conflicts"]]
assert any("DB专属词" in s for s in suggestions)
assert any("最好" in s for s in suggestions)
@pytest.mark.asyncio
async def test_unknown_platform_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""未知平台返回空冲突(无硬编码规则,无 DB 规则)"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
json={
"brand_id": brand_id,
"platform": "unknown_platform",
"brief_rules": {
"selling_points": ["最好的产品"],
}
}
)
data = response.json()
assert data["conflicts"] == []
# ==================== 品牌方平台规则(文档上传 + AI 解析) ====================

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

@ -149,7 +149,7 @@ function mapTaskToAppeal(task: TaskResponse): Appeal {
taskTitle: task.name,
creatorId: task.creator.id,
creatorName: task.creator.name,
platform: 'douyin', // Backend does not expose platform on task; default for now
platform: task.project?.platform || 'douyin',
type,
contentType,
reason: task.appeal_reason || '申诉',

File diff suppressed because it is too large Load Diff

View File

@ -141,7 +141,7 @@ export default function AgencyBriefsPage() {
projectId: project.id,
projectName: project.name,
brandName: project.brand_name || '未知品牌',
platform: 'douyin', // 后端暂无 platform 字段,默认值
platform: project.platform || 'douyin',
status: hasBrief ? 'configured' : 'pending',
uploadedAt: project.created_at.split('T')[0],
configuredAt: hasBrief ? brief.updated_at.split('T')[0] : null,
@ -156,7 +156,7 @@ export default function AgencyBriefsPage() {
projectId: project.id,
projectName: project.name,
brandName: project.brand_name || '未知品牌',
platform: 'douyin',
platform: project.platform || 'douyin',
status: 'pending',
uploadedAt: project.created_at.split('T')[0],
configuredAt: null,
@ -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

@ -245,7 +245,7 @@ export default function AgencyCreatorsPage() {
id: task.id,
name: task.name,
projectName: task.project?.name || '-',
platform: 'douyin', // 后端暂未返回平台信息,默认
platform: task.project?.platform || 'douyin',
stage: mapBackendStage(task.stage),
appealRemaining: task.appeal_count,
appealUsed: task.is_appeal ? 1 : 0,
@ -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

@ -45,6 +45,11 @@ type MessageType =
| 'task_deadline' // 任务截止提醒
| 'brand_brief_updated' // 品牌方更新了Brief
| 'system_notice' // 系统通知
| 'new_task' // 新任务
| 'pass' // 审核通过
| 'reject' // 审核驳回
| 'force_pass' // 强制通过
| 'approve' // 审核批准
interface Message {
id: string
@ -299,19 +304,31 @@ export default function AgencyMessagesPage() {
}
try {
const res = await api.getMessages({ page: 1, page_size: 50 })
const mapped: Message[] = res.items.map(item => ({
id: item.id,
type: (item.type || 'system_notice') as MessageType,
title: item.title,
content: item.content,
time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '',
read: item.is_read,
icon: Bell,
iconColor: 'text-text-secondary',
bgColor: 'bg-bg-elevated',
taskId: item.related_task_id || undefined,
projectId: item.related_project_id || undefined,
}))
const typeIconMap: Record<string, { icon: typeof Bell; iconColor: string; bgColor: string }> = {
new_task: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
pass: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
force_pass: { icon: CheckCircle, iconColor: 'text-accent-amber', bgColor: 'bg-accent-amber/20' },
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
}
const defaultIcon = { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' }
const mapped: Message[] = res.items.map(item => {
const iconCfg = typeIconMap[item.type] || defaultIcon
return {
id: item.id,
type: (item.type || 'system_notice') as MessageType,
title: item.title,
content: item.content,
time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '',
read: item.is_read,
icon: iconCfg.icon,
iconColor: iconCfg.iconColor,
bgColor: iconCfg.bgColor,
taskId: item.related_task_id || undefined,
projectId: item.related_project_id || undefined,
}
})
setMessages(mapped)
} catch {
// 加载失败保持 mock 数据

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

@ -8,8 +8,13 @@ import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { getPlatformInfo } from '@/lib/platforms'
import type { TaskResponse, TaskStage } from '@/types/task'
function getPlatformLabel(platformId: string): string {
return getPlatformInfo(platformId)?.name || platformId
}
// ==================== 本地视图模型 ====================
interface TaskViewModel {
id: string
@ -225,7 +230,7 @@ function mapTaskResponseToViewModel(task: TaskResponse): TaskViewModel {
videoTitle: task.name,
creatorName: task.creator?.name || '未知达人',
brandName: task.project?.brand_name || '未知品牌',
platform: '小红书', // 后端暂无 platform 字段
platform: task.project?.platform ? getPlatformLabel(task.project.platform) : '未知平台',
status,
aiScore,
finalScore,

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

@ -45,6 +45,10 @@ type MessageType =
| 'brief_config_updated' // 代理商更新了Brief配置
| 'batch_review_done' // 批量审核完成
| 'system_notice' // 系统通知
| 'new_task' // 新任务分配
| 'pass' // 审核通过
| 'reject' // 审核驳回
| 'approve' // 审核批准
type Message = {
id: string
@ -80,6 +84,10 @@ const messageConfig: Record<MessageType, {
brief_config_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
batch_review_done: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
new_task: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
pass: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
}
// 模拟消息数据
@ -412,7 +420,7 @@ export default function BrandMessagesPage() {
{/* 消息列表 */}
<div className="space-y-3">
{filteredMessages.map((message) => {
const config = messageConfig[message.type]
const config = messageConfig[message.type] || messageConfig.system_notice
const Icon = config.icon
const platform = message.platform ? getPlatformInfo(message.platform) : null

View File

@ -22,28 +22,29 @@ import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import { useToast } from '@/components/ui/Toast'
import { getPlatformInfo } from '@/lib/platforms'
import type { ProjectResponse } from '@/types/project'
// ==================== Mock 数据 ====================
const mockProjects: ProjectResponse[] = [
{
id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-06-18', agencies: [],
platform: 'douyin', status: 'active', deadline: '2026-06-18', agencies: [],
task_count: 20, created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-05T00:00:00Z',
},
{
id: 'proj-002', name: '新品口红系列', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-03-15', agencies: [],
platform: 'xiaohongshu', status: 'active', deadline: '2026-03-15', agencies: [],
task_count: 12, created_at: '2026-01-15T00:00:00Z', updated_at: '2026-02-01T00:00:00Z',
},
{
id: 'proj-003', name: '护肤品秋季活动', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'completed', deadline: '2025-11-30', agencies: [],
platform: 'bilibili', status: 'completed', deadline: '2025-11-30', agencies: [],
task_count: 15, created_at: '2025-08-01T00:00:00Z', updated_at: '2025-11-30T00:00:00Z',
},
{
id: 'proj-004', name: '双11预热活动', brand_id: 'br-001', brand_name: 'XX品牌',
status: 'active', deadline: '2026-11-11', agencies: [],
platform: 'kuaishou', status: 'active', deadline: '2026-11-11', agencies: [],
task_count: 18, created_at: '2026-01-10T00:00:00Z', updated_at: '2026-02-04T00:00:00Z',
},
]
@ -58,11 +59,25 @@ function StatusTag({ status }: { status: string }) {
}
function ProjectCard({ project, onEditDeadline }: { project: ProjectResponse; onEditDeadline: (project: ProjectResponse) => void }) {
const platformInfo = project.platform ? getPlatformInfo(project.platform) : null
return (
<Link href={`/brand/projects/${project.id}`}>
<Card className="hover:border-accent-indigo/50 transition-colors cursor-pointer h-full overflow-hidden">
<div className="px-6 py-2 bg-accent-indigo/10 border-b border-accent-indigo/20 flex items-center justify-between">
<span className="text-sm font-medium text-accent-indigo">{project.brand_name || '品牌项目'}</span>
<div className={`px-6 py-2 border-b flex items-center justify-between ${
platformInfo
? `${platformInfo.bgColor} ${platformInfo.borderColor}`
: 'bg-accent-indigo/10 border-accent-indigo/20'
}`}>
<span className={`text-sm font-medium flex items-center gap-1.5 ${
platformInfo ? platformInfo.textColor : 'text-accent-indigo'
}`}>
{platformInfo ? (
<><span>{platformInfo.icon}</span>{platformInfo.name}</>
) : (
project.brand_name || '品牌项目'
)}
</span>
<StatusTag status={project.status} />
</div>
<CardContent className="p-6 space-y-4">

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent } from '@/components/ui/Card'
@ -13,6 +13,7 @@ import {
Plus,
Trash2,
AlertTriangle,
AlertCircle,
CheckCircle,
Bot,
Users,
@ -20,13 +21,28 @@ import {
Upload,
ChevronDown,
ChevronUp,
Loader2
Loader2,
Search,
RotateCcw
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
import type { RuleConflict } from '@/types/rules'
import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
// 单个文件的上传状态
interface UploadFileItem {
id: string
name: string
size: string
status: 'uploading' | 'success' | 'error'
progress: number
url?: string
error?: string
file?: File
}
// ==================== Mock 数据 ====================
const mockBrief: BriefResponse = {
id: 'bf-001',
@ -81,6 +97,13 @@ const mockRules = {
},
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB'
}
// 严格程度选项
const strictnessOptions = [
{ value: 'low', label: '宽松', description: '仅检测明显违规内容' },
@ -109,8 +132,11 @@ export default function ProjectConfigPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const { user } = useAuth()
const projectId = params.id as string
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
// 附件上传跟踪
const [uploadingFiles, setUploadingFiles] = useState<UploadFileItem[]>([])
// Brief state
const [briefExists, setBriefExists] = useState(false)
@ -133,6 +159,82 @@ export default function ProjectConfigPage() {
const [isSaving, setIsSaving] = useState(false)
const [activeSection, setActiveSection] = useState<string | null>('brief')
// 规则冲突检测
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [showConflictModal, setShowConflictModal] = useState(false)
const [conflicts, setConflicts] = useState<RuleConflict[]>([])
const [showPlatformSelect, setShowPlatformSelect] = useState(false)
const platformDropdownRef = useRef<HTMLDivElement>(null)
const platformOptions = [
{ value: 'douyin', label: '抖音' },
{ value: 'xiaohongshu', label: '小红书' },
{ value: 'bilibili', label: 'B站' },
]
// 点击外部关闭平台选择下拉
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (platformDropdownRef.current && !platformDropdownRef.current.contains(e.target as Node)) {
setShowPlatformSelect(false)
}
}
if (showPlatformSelect) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showPlatformSelect])
const handleCheckConflicts = async (platform: string) => {
setShowPlatformSelect(false)
setIsCheckingConflicts(true)
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
setConflicts([
{
brief_rule: '卖点包含100%纯天然成分',
platform_rule: `${platform} 禁止使用100%`,
suggestion: "卖点 '100%纯天然成分' 包含违禁词 '100%',建议修改表述",
},
{
brief_rule: 'Brief 最长时长5秒',
platform_rule: `${platform} 最短要求7秒`,
suggestion: 'Brief 最长 5s 低于平台最短要求 7s视频可能不达标',
},
])
setShowConflictModal(true)
setIsCheckingConflicts(false)
return
}
try {
const brandId = user?.brand_id || ''
const briefRules: Record<string, unknown> = {
selling_points: sellingPoints.map(sp => sp.content),
min_duration: minDuration,
max_duration: maxDuration,
}
const result = await api.validateRules({
brand_id: brandId,
platform,
brief_rules: briefRules,
})
setConflicts(result.conflicts)
if (result.conflicts.length > 0) {
setShowConflictModal(true)
} else {
toast.success('未发现规则冲突')
}
} catch (err) {
console.error('规则冲突检测失败:', err)
toast.error('规则冲突检测失败')
} finally {
setIsCheckingConflicts(false)
}
}
// Input fields
const [newSellingPoint, setNewSellingPoint] = useState('')
const [newBlacklistWord, setNewBlacklistWord] = useState('')
@ -254,32 +356,71 @@ export default function ProjectConfigPage() {
setCompetitors(competitors.filter(c => c !== name))
}
// Attachment upload
const handleAttachmentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 上传单个附件(独立跟踪进度)
const uploadSingleAttachment = async (file: File, fileId: string) => {
if (USE_MOCK) {
setAttachments([...attachments, {
id: `att-${Date.now()}`,
name: file.name,
url: `mock://${file.name}`,
}])
for (let p = 20; p <= 80; p += 20) {
await new Promise(r => setTimeout(r, 300))
setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
}
await new Promise(r => setTimeout(r, 300))
const att: BriefAttachment = { id: fileId, name: file.name, url: `mock://${file.name}`, size: formatFileSize(file.size) }
setAttachments(prev => [...prev, att])
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
return
}
try {
const result = await upload(file)
setAttachments([...attachments, {
id: `att-${Date.now()}`,
name: file.name,
url: result.url,
}])
} catch {
toast.error('文件上传失败')
const result = await api.proxyUpload(file, 'general', (pct) => {
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) }
: f
))
})
const att: BriefAttachment = { id: fileId, name: file.name, url: result.url, size: formatFileSize(file.size) }
setAttachments(prev => [...prev, att])
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'error', error: msg }
: f
))
}
}
const handleAttachmentUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const fileList = Array.from(files)
e.target.value = ''
const newItems: UploadFileItem[] = fileList.map(file => ({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
size: formatFileSize(file.size),
status: 'uploading' as const,
progress: 0,
file,
}))
setUploadingFiles(prev => [...prev, ...newItems])
newItems.forEach(item => uploadSingleAttachment(item.file!, item.id))
}
const retryAttachmentUpload = (fileId: string) => {
const item = uploadingFiles.find(f => f.id === fileId)
if (!item?.file) return
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'uploading', progress: 0, error: undefined }
: f
))
uploadSingleAttachment(item.file, fileId)
}
const removeUploadingFile = (id: string) => {
setUploadingFiles(prev => prev.filter(f => f.id !== id))
}
const removeAttachment = (id: string) => {
setAttachments(attachments.filter(a => a.id !== id))
}
@ -336,19 +477,54 @@ export default function ProjectConfigPage() {
</p>
</div>
</div>
<Button variant="primary" onClick={handleSaveBrief} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Save size={16} />
</>
)}
</Button>
<div className="flex items-center gap-2">
<div className="relative" ref={platformDropdownRef}>
<Button
variant="secondary"
onClick={() => setShowPlatformSelect(!showPlatformSelect)}
disabled={isCheckingConflicts}
>
{isCheckingConflicts ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Search size={16} />
</>
)}
</Button>
{showPlatformSelect && (
<div className="absolute right-0 top-full mt-2 w-40 bg-bg-card border border-border-subtle rounded-xl shadow-lg z-50 overflow-hidden">
{platformOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleCheckConflicts(opt.value)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated transition-colors"
>
{opt.label}
</button>
))}
</div>
)}
</div>
<Button variant="primary" onClick={handleSaveBrief} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Save size={16} />
</>
)}
</Button>
</div>
</div>
{/* Brief配置 */}
@ -514,40 +690,99 @@ export default function ProjectConfigPage() {
{/* 参考资料 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="space-y-2">
{attachments.map((att) => (
<div key={att.id} className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
<FileText size={16} className="text-accent-indigo" />
<span className="flex-1 text-text-primary">{att.name}</span>
{att.size && <span className="text-xs text-text-tertiary">{att.size}</span>}
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="p-1 rounded hover:bg-bg-page text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
</button>
<label className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-dashed border-border-subtle bg-bg-elevated text-text-primary hover:border-accent-indigo/50 hover:bg-bg-page transition-colors cursor-pointer w-full text-sm mb-3">
<Upload size={16} className="text-accent-indigo" />
<input
type="file"
multiple
onChange={handleAttachmentUpload}
className="hidden"
/>
</label>
{/* 文件列表 */}
<div className="border border-border-subtle rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-bg-elevated border-b border-border-subtle">
<span className="text-xs font-medium text-text-secondary flex items-center gap-1.5">
<FileText size={12} className="text-accent-indigo" />
</span>
<span className="text-xs text-text-tertiary">
{attachments.length + uploadingFiles.filter(f => f.status === 'uploading').length}
{uploadingFiles.some(f => f.status === 'uploading') && (
<span className="text-accent-indigo ml-1">· </span>
)}
</span>
</div>
{attachments.length === 0 && uploadingFiles.length === 0 ? (
<div className="px-4 py-5 text-center">
<p className="text-xs text-text-tertiary"></p>
</div>
))}
<label className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-border-subtle bg-bg-elevated text-text-primary hover:bg-bg-page transition-colors cursor-pointer w-full text-sm">
{isUploading ? (
<>
<Loader2 size={16} className="animate-spin" />
{uploadProgress}%
</>
) : (
<>
<Upload size={16} />
</>
)}
<input
type="file"
onChange={handleAttachmentUpload}
className="hidden"
disabled={isUploading}
/>
</label>
) : (
<div className="divide-y divide-border-subtle">
{/* 已完成的文件 */}
{attachments.map((att) => (
<div key={att.id} className="flex items-center gap-3 px-4 py-2.5">
<CheckCircle size={14} className="text-accent-green flex-shrink-0" />
<FileText size={14} className="text-text-tertiary flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary truncate">{att.name}</span>
{att.size && <span className="text-xs text-text-tertiary">{att.size}</span>}
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="p-1 rounded hover:bg-bg-elevated text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
{/* 上传中/失败的文件 */}
{uploadingFiles.map((file) => (
<div key={file.id} className="px-4 py-2.5">
<div className="flex items-center gap-3">
{file.status === 'uploading' && (
<Loader2 size={14} className="animate-spin text-accent-indigo flex-shrink-0" />
)}
{file.status === 'error' && (
<AlertCircle size={14} className="text-accent-coral flex-shrink-0" />
)}
<FileText size={14} className="text-text-tertiary flex-shrink-0" />
<span className={`flex-1 text-sm truncate ${
file.status === 'error' ? 'text-accent-coral' : 'text-text-primary'
}`}>{file.name}</span>
<span className="text-xs text-text-tertiary whitespace-nowrap min-w-[40px] text-right">
{file.status === 'uploading' ? `${file.progress}%` : file.size}
</span>
{file.status === 'error' && (
<button type="button" onClick={() => retryAttachmentUpload(file.id)}
className="p-1 rounded hover:bg-bg-elevated text-accent-indigo transition-colors" title="重试">
<RotateCcw size={14} />
</button>
)}
{file.status !== 'uploading' && (
<button type="button" onClick={() => removeUploadingFile(file.id)}
className="p-1 rounded hover:bg-bg-elevated text-text-tertiary hover:text-accent-coral transition-colors" title="删除">
<Trash2 size={14} />
</button>
)}
</div>
{file.status === 'uploading' && (
<div className="mt-1.5 ml-[28px] h-2 bg-bg-page rounded-full overflow-hidden">
<div className="h-full bg-accent-indigo rounded-full transition-all duration-300"
style={{ width: `${file.progress}%` }} />
</div>
)}
{file.status === 'error' && file.error && (
<p className="mt-1 ml-[28px] text-xs text-accent-coral">{file.error}</p>
)}
</div>
))}
</div>
)}
</div>
</div>
</CardContent>
@ -757,6 +992,54 @@ export default function ProjectConfigPage() {
</CardContent>
)}
</Card>
{/* 规则冲突检测结果弹窗 */}
<Modal
isOpen={showConflictModal}
onClose={() => setShowConflictModal(false)}
title="规则冲突检测结果"
size="lg"
>
<div className="space-y-4">
{conflicts.length === 0 ? (
<div className="py-8 text-center">
<CheckCircle size={48} className="mx-auto text-accent-green mb-3" />
<p className="text-text-primary font-medium"></p>
<p className="text-sm text-text-secondary mt-1">Brief </p>
</div>
) : (
<>
<div className="flex items-center gap-2 p-3 bg-accent-amber/10 rounded-lg border border-accent-amber/30">
<AlertTriangle size={16} className="text-accent-amber flex-shrink-0" />
<p className="text-sm text-accent-amber">
{conflicts.length}
</p>
</div>
{conflicts.map((conflict, index) => (
<div key={index} className="p-4 bg-bg-elevated rounded-xl border border-border-subtle space-y-2">
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-amber bg-accent-amber/15 px-2 py-0.5 rounded">Brief</span>
<span className="text-sm text-text-primary">{conflict.brief_rule}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-coral bg-accent-coral/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-primary">{conflict.platform_rule}</span>
</div>
<div className="flex items-start gap-2 pt-1 border-t border-border-subtle">
<span className="text-xs font-medium text-accent-indigo bg-accent-indigo/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-secondary">{conflict.suggestion}</span>
</div>
</div>
))}
</>
)}
<div className="flex justify-end pt-2">
<Button variant="secondary" onClick={() => setShowConflictModal(false)}>
</Button>
</div>
</div>
</Modal>
</div>
)
}

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

@ -12,17 +12,38 @@ import {
Calendar,
FileText,
CheckCircle,
X,
Users,
AlertCircle,
Search,
Building2,
Check,
Loader2
Loader2,
Trash2,
RotateCcw
} from 'lucide-react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import { platformOptions } from '@/lib/platforms'
import type { AgencyDetail } from '@/types/organization'
import type { BriefAttachment } from '@/types/brief'
// 单个文件的上传状态
interface UploadFileItem {
id: string
name: string
size: string
rawSize: number
status: 'uploading' | 'success' | 'error'
progress: number
url?: string
error?: string
file?: File // 保留引用用于重试
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB'
}
// ==================== Mock 数据 ====================
const mockAgencies: AgencyDetail[] = [
@ -37,19 +58,25 @@ const mockAgencies: AgencyDetail[] = [
export default function CreateProjectPage() {
const router = useRouter()
const toast = useToast()
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
const [projectName, setProjectName] = useState('')
const [description, setDescription] = useState('')
const [platform, setPlatform] = useState('douyin')
const [deadline, setDeadline] = useState('')
const [briefFile, setBriefFile] = useState<File | null>(null)
const [briefFileUrl, setBriefFileUrl] = useState<string | null>(null)
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([])
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [agencySearch, setAgencySearch] = useState('')
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
const [loadingAgencies, setLoadingAgencies] = useState(true)
// 从成功上传的文件中提取 BriefAttachment
const briefFiles: BriefAttachment[] = uploadFiles
.filter(f => f.status === 'success' && f.url)
.map(f => ({ id: f.id, name: f.name, url: f.url!, size: f.size }))
const hasUploading = uploadFiles.some(f => f.status === 'uploading')
useEffect(() => {
const loadAgencies = async () => {
if (USE_MOCK) {
@ -76,22 +103,85 @@ export default function CreateProjectPage() {
agency.id.toLowerCase().includes(agencySearch.toLowerCase())
)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setBriefFile(file)
if (!USE_MOCK) {
try {
const result = await upload(file)
setBriefFileUrl(result.url)
} catch (err) {
toast.error('文件上传失败')
setBriefFile(null)
// 上传单个文件(独立跟踪进度)
const uploadSingleFile = async (file: File, fileId: string) => {
if (USE_MOCK) {
// Mock模拟进度
for (let p = 20; p <= 80; p += 20) {
await new Promise(r => setTimeout(r, 300))
setUploadFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
}
} else {
setBriefFileUrl('mock://brief-file.pdf')
await new Promise(r => setTimeout(r, 300))
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'success', progress: 100, url: `mock://${file.name}` }
: f
))
toast.success(`${file.name} 上传完成`)
return
}
try {
const result = await api.proxyUpload(file, 'general', (pct) => {
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) }
: f
))
})
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'success', progress: 100, url: result.url }
: f
))
toast.success(`${file.name} 上传完成`)
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'error', error: msg }
: f
))
toast.error(`${file.name} 上传失败: ${msg}`)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const fileList = Array.from(files)
e.target.value = ''
toast.info(`已选择 ${fileList.length} 个文件,开始上传...`)
// 立即添加所有文件到列表uploading 状态)
const newItems: UploadFileItem[] = fileList.map(file => ({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
size: formatFileSize(file.size),
rawSize: file.size,
status: 'uploading' as const,
progress: 0,
file,
}))
setUploadFiles(prev => [...prev, ...newItems])
// 并发上传所有文件
newItems.forEach(item => {
uploadSingleFile(item.file!, item.id)
})
}
// 重试失败的上传
const retryUpload = (fileId: string) => {
const item = uploadFiles.find(f => f.id === fileId)
if (!item?.file) return
setUploadFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'uploading', progress: 0, error: undefined }
: f
))
uploadSingleFile(item.file, fileId)
}
const removeFile = (id: string) => {
setUploadFiles(prev => prev.filter(f => f.id !== id))
}
const toggleAgency = (agencyId: string) => {
@ -116,15 +206,15 @@ export default function CreateProjectPage() {
const project = await api.createProject({
name: projectName.trim(),
description: description.trim() || undefined,
platform,
deadline,
agency_ids: selectedAgencies,
})
// If brief file was uploaded, create brief
if (briefFileUrl && briefFile) {
// If brief files were uploaded, create brief with attachments
if (briefFiles.length > 0) {
await api.createBrief(project.id, {
file_url: briefFileUrl,
file_name: briefFile.name,
attachments: briefFiles,
})
}
}
@ -177,6 +267,35 @@ export default function CreateProjectPage() {
/>
</div>
{/* 发布平台 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-accent-coral">*</span>
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{platformOptions.map((p) => {
const isSelected = platform === p.id
return (
<button
key={p.id}
type="button"
onClick={() => setPlatform(p.id)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border-2 transition-all ${
isSelected
? `${p.borderColor} ${p.bgColor} border-opacity-100`
: 'border-border-subtle hover:border-accent-indigo/30'
}`}
>
<span className="text-xl">{p.icon}</span>
<span className={`font-medium ${isSelected ? p.textColor : 'text-text-secondary'}`}>
{p.name}
</span>
</button>
)
})}
</div>
</div>
{/* 截止日期 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
@ -195,35 +314,125 @@ export default function CreateProjectPage() {
{/* Brief 上传 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"> Brief</label>
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{briefFile ? (
<div className="flex items-center justify-center gap-3">
<FileText size={24} className="text-accent-indigo" />
<span className="text-text-primary">{briefFile.name}</span>
{isUploading && (
<span className="text-xs text-text-tertiary">{uploadProgress}%</span>
)}
<button
type="button"
onClick={() => { setBriefFile(null); setBriefFileUrl(null) }}
className="p-1 hover:bg-bg-elevated rounded-full"
>
<X size={16} className="text-text-tertiary" />
</button>
<label className="block text-sm font-medium text-text-primary mb-2">
Brief
</label>
{/* 上传区域 */}
<label className="border-2 border-dashed border-border-subtle rounded-lg p-6 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block mb-3">
<Upload size={28} className="mx-auto text-text-tertiary mb-2" />
<p className="text-text-secondary text-sm mb-1">
{uploadFiles.length > 0 ? '继续添加文件' : '点击上传 Brief 文件(可多选)'}
</p>
<p className="text-xs text-text-tertiary"> PDFWordExcel</p>
<input
type="file"
multiple
onChange={handleFileChange}
className="hidden"
/>
</label>
{/* 文件列表(含进度)— 始终显示,空状态也有提示 */}
<div className={`border rounded-lg overflow-hidden ${uploadFiles.length > 0 ? 'border-accent-indigo/40 bg-accent-indigo/5' : 'border-border-subtle'}`}>
<div className={`flex items-center justify-between px-4 py-2.5 border-b ${uploadFiles.length > 0 ? 'bg-accent-indigo/10 border-accent-indigo/20' : 'bg-bg-elevated border-border-subtle'}`}>
<span className="text-sm font-medium text-text-primary flex items-center gap-2">
<FileText size={14} className="text-accent-indigo" />
</span>
{uploadFiles.length > 0 && (
<span className="text-xs text-text-tertiary">
{briefFiles.length}/{uploadFiles.length}
{uploadFiles.some(f => f.status === 'error') && (
<span className="text-accent-coral ml-1">
· {uploadFiles.filter(f => f.status === 'error').length}
</span>
)}
{hasUploading && (
<span className="text-accent-indigo ml-1">
· ...
</span>
)}
</span>
)}
</div>
{uploadFiles.length === 0 ? (
<div className="px-4 py-6 text-center">
<p className="text-sm text-text-tertiary"></p>
</div>
) : (
<label className="cursor-pointer">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"> Brief </p>
<p className="text-xs text-text-tertiary"> PDFWordExcel </p>
<input
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx"
onChange={handleFileChange}
className="hidden"
/>
</label>
<div className="divide-y divide-border-subtle">
{uploadFiles.map((file) => (
<div key={file.id} className="px-4 py-3">
<div className="flex items-center gap-3">
{/* 状态图标 */}
{file.status === 'uploading' && (
<Loader2 size={16} className="animate-spin text-accent-indigo flex-shrink-0" />
)}
{file.status === 'success' && (
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
)}
{file.status === 'error' && (
<AlertCircle size={16} className="text-accent-coral flex-shrink-0" />
)}
{/* 文件图标+文件名 */}
<FileText size={14} className="text-text-tertiary flex-shrink-0" />
<span className={`flex-1 text-sm truncate ${
file.status === 'error' ? 'text-accent-coral' : 'text-text-primary'
}`}>
{file.name}
</span>
{/* 大小/进度文字 */}
<span className="text-xs text-text-tertiary whitespace-nowrap min-w-[48px] text-right">
{file.status === 'uploading'
? `${file.progress}%`
: file.size
}
</span>
{/* 操作按钮 */}
{file.status === 'error' && (
<button
type="button"
onClick={() => retryUpload(file.id)}
className="p-1 rounded hover:bg-bg-elevated text-accent-indigo transition-colors"
title="重试"
>
<RotateCcw size={14} />
</button>
)}
{file.status !== 'uploading' && (
<button
type="button"
onClick={() => removeFile(file.id)}
className="p-1 rounded hover:bg-bg-elevated text-text-tertiary hover:text-accent-coral transition-colors"
title="删除"
>
<Trash2 size={14} />
</button>
)}
</div>
{/* 进度条 */}
{file.status === 'uploading' && (
<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: `${file.progress}%` }}
/>
</div>
)}
{/* 错误提示 */}
{file.status === 'error' && file.error && (
<p className="mt-1 ml-[30px] text-xs text-accent-coral">{file.error}</p>
)}
</div>
))}
</div>
)}
</div>
</div>
@ -311,7 +520,7 @@ export default function CreateProjectPage() {
<Button variant="secondary" onClick={() => router.back()}>
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || isUploading}>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || hasUploading}>
{isSubmitting ? (
<>
<Loader2 size={16} className="animate-spin" />

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

@ -8,7 +8,7 @@ import { Modal } from '@/components/ui/Modal'
import { useToast } from '@/components/ui/Toast'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
// upload via api.proxyUpload directly
import type {
ForbiddenWordResponse,
CompetitorResponse,
@ -192,7 +192,8 @@ function ListSkeleton({ count = 3 }: { count?: number }) {
export default function RulesPage() {
const toast = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const { upload: ossUpload, isUploading: isOssUploading, progress: ossProgress } = useOSSUpload('rules')
const [isOssUploading, setIsOssUploading] = useState(false)
const [ossProgress, setOssProgress] = useState(0)
// Tab 选择
const [activeTab, setActiveTab] = useState<'platforms' | 'forbidden' | 'competitors' | 'whitelist'>('platforms')
@ -337,8 +338,14 @@ export default function RulesPage() {
return
}
// 真实模式: 上传到 TOS
const uploadResult = await ossUpload(uploadFile)
// 真实模式: 上传到 TOS (通过后端代理)
setIsOssUploading(true)
setOssProgress(0)
const uploadResult = await api.proxyUpload(uploadFile, 'rules', (pct) => {
setOssProgress(Math.min(95, Math.round(pct * 0.95)))
})
setOssProgress(100)
setIsOssUploading(false)
documentUrl = uploadResult.url
// 调用 AI 解析
@ -374,6 +381,7 @@ export default function RulesPage() {
toast.error('文档解析失败:' + (err instanceof Error ? err.message : '未知错误'))
} finally {
setParsing(false)
setIsOssUploading(false)
}
}
@ -574,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

@ -47,6 +47,9 @@ type MessageType =
| 'task_deadline' // 任务截止提醒
| 'brief_updated' // Brief更新通知
| 'system_notice' // 系统通知
| 'reject' // 审核驳回
| 'force_pass' // 强制通过
| 'approve' // 审核批准
type Message = {
id: string
@ -87,6 +90,9 @@ const messageConfig: Record<MessageType, {
task_deadline: { icon: CalendarClock, iconColor: 'text-orange-400', bgColor: 'bg-orange-500/20' },
brief_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
force_pass: { icon: CheckCircle, iconColor: 'text-accent-amber', bgColor: 'bg-accent-amber/20' },
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
}
// 12条消息数据
@ -281,7 +287,7 @@ function MessageCard({
onAcceptInvite?: () => void
onIgnoreInvite?: () => void
}) {
const config = messageConfig[message.type]
const config = messageConfig[message.type] || messageConfig.system_notice
const Icon = config.icon
return (

View File

@ -82,7 +82,7 @@ function mapTaskResponseToUI(task: TaskResponse): Task {
id: task.id,
title: task.name,
description: `${task.project.name} · ${ui.statusLabel}`,
platform: 'douyin', // 后端暂无平台字段,默认
platform: task.project?.platform || 'douyin',
scriptStage: ui.scriptStage,
videoStage: ui.videoStage,
buttonText: ui.buttonText,

View File

@ -32,6 +32,7 @@ type AgencyBriefFile = {
size: string
uploadedAt: string
description?: string
url?: string
}
// 页面视图模型
@ -102,20 +103,24 @@ function buildMockViewModel(): BriefViewModel {
}
function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefViewModel {
// Map attachments to file list
const files: AgencyBriefFile[] = (brief.attachments ?? []).map((att, idx) => ({
// 优先显示代理商上传的文档,没有则降级到品牌方附件
const agencyAtts = brief.agency_attachments ?? []
const brandAtts = brief.attachments ?? []
const sourceAtts = agencyAtts.length > 0 ? agencyAtts : brandAtts
const files: AgencyBriefFile[] = sourceAtts.map((att, idx) => ({
id: att.id || `att-${idx}`,
name: att.name,
size: att.size || '',
uploadedAt: brief.updated_at?.split('T')[0] || '',
description: undefined,
url: att.url,
}))
// Map selling points
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
@ -233,12 +238,21 @@ export default function TaskBriefPage() {
loadBriefData()
}, [loadBriefData])
const handleDownload = (file: AgencyBriefFile) => {
toast.info(`下载文件: ${file.name}`)
const handleDownload = async (file: AgencyBriefFile) => {
if (USE_MOCK || !file.url) {
toast.info(`下载文件: ${file.name}`)
return
}
try {
await api.downloadFile(file.url, file.name)
} catch {
toast.error('下载失败')
}
}
const handleDownloadAll = () => {
toast.info('下载全部文件')
if (!viewModel) return
viewModel.files.forEach(f => handleDownload(f))
}
if (loading || !viewModel) {

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,10 +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 { useOSSUpload } from '@/hooks/useOSSUpload'
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 }
@ -30,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 }
@ -39,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 }[]
}
@ -65,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' ? {
@ -101,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 })),
}
}
@ -113,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: '绝对化用语' },
@ -134,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 (
<>
@ -173,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>
@ -217,64 +235,109 @@ function AgencyBriefSection({ toast, briefData }: { toast: ReturnType<typeof use
function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
const [file, setFile] = useState<File | null>(null)
const { upload, isUploading, progress } = useOSSUpload('script')
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
const toast = useToast()
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) setFile(selectedFile)
if (selectedFile) {
setFile(selectedFile)
setUploadError(null)
}
}
const handleSubmit = async () => {
if (!file) return
setIsUploading(true)
setProgress(0)
setUploadError(null)
try {
const result = await upload(file)
if (!USE_MOCK) {
if (USE_MOCK) {
for (let i = 0; i <= 100; i += 20) {
await new Promise(r => setTimeout(r, 400))
setProgress(i)
}
toast.success('脚本已提交,等待 AI 审核')
onUploaded()
} else {
const result = await api.proxyUpload(file, 'script', (pct) => {
setProgress(Math.min(90, Math.round(pct * 0.9)))
})
setProgress(95)
await api.uploadTaskScript(taskId, { file_url: result.url, file_name: result.file_name })
setProgress(100)
toast.success('脚本已提交,等待 AI 审核')
onUploaded()
}
toast.success('脚本已提交,等待 AI 审核')
onUploaded()
} catch (err) {
toast.error(err instanceof Error ? err.message : '上传失败')
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'
}
return (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-accent-indigo" /></CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{file ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-3">
<FileText size={24} className="text-accent-indigo" />
<span className="text-text-primary">{file.name}</span>
{!file ? (
<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"> 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">
<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 size={16} className="animate-spin text-accent-indigo flex-shrink-0" />
) : uploadError ? (
<AlertTriangle size={16} className="text-accent-coral flex-shrink-0" />
) : (
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
)}
<FileText size={14} className="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)} className="p-1 hover:bg-bg-elevated rounded-full">
<XCircle size={16} className="text-text-tertiary" />
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
<XCircle size={14} className="text-text-tertiary" />
</button>
)}
</div>
{isUploading && (
<div className="w-full max-w-xs mx-auto">
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
<div className="h-full bg-accent-indigo transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-sm text-text-tertiary"> {progress}%</p>
<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>
)}
{isUploading && (
<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>
) : (
<label className="cursor-pointer">
<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" />
</label>
)}
</div>
</div>
)}
<Button onClick={handleSubmit} disabled={!file || isUploading} fullWidth>
{isUploading ? '上传中...' : '提交脚本'}
{isUploading ? (
<><Loader2 size={16} className="animate-spin" /> {progress}%</>
) : '提交脚本'}
</Button>
</CardContent>
</Card>
@ -311,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>
@ -322,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>
)
@ -440,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

@ -14,7 +14,6 @@ import {
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import type { TaskResponse } from '@/types/task'
// ========== 类型 ==========
@ -102,64 +101,109 @@ function formatTimestamp(seconds: number): string {
function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
const [file, setFile] = useState<File | null>(null)
const { upload, isUploading, progress } = useOSSUpload('video')
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
const toast = useToast()
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) setFile(selectedFile)
if (selectedFile) {
setFile(selectedFile)
setUploadError(null)
}
}
const handleUpload = async () => {
if (!file) return
setIsUploading(true)
setProgress(0)
setUploadError(null)
try {
const result = await upload(file)
if (!USE_MOCK) {
if (USE_MOCK) {
for (let i = 0; i <= 100; i += 10) {
await new Promise(r => setTimeout(r, 300))
setProgress(i)
}
toast.success('视频已提交,等待 AI 审核')
onUploaded()
} else {
const result = await api.proxyUpload(file, 'video', (pct) => {
setProgress(Math.min(90, Math.round(pct * 0.9)))
})
setProgress(95)
await api.uploadTaskVideo(taskId, { file_url: result.url, file_name: result.file_name })
setProgress(100)
toast.success('视频已提交,等待 AI 审核')
onUploaded()
}
toast.success('视频已提交,等待 AI 审核')
onUploaded()
} catch (err) {
toast.error(err instanceof Error ? err.message : '上传失败')
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'
}
return (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-purple-400" /></CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{file ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-3">
<Video size={24} className="text-purple-400" />
<span className="text-text-primary">{file.name}</span>
{!file ? (
<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"> MP4MOVAVI 500MB</p>
<input type="file" accept="video/*" onChange={handleFileChange} className="hidden" />
</label>
) : (
<div className="border border-border-subtle rounded-lg 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 size={16} className="animate-spin text-purple-400 flex-shrink-0" />
) : uploadError ? (
<AlertTriangle size={16} className="text-accent-coral flex-shrink-0" />
) : (
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
)}
<Video size={14} className="text-purple-400 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)} className="p-1 hover:bg-bg-elevated rounded-full">
<XCircle size={16} className="text-text-tertiary" />
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
<XCircle size={14} className="text-text-tertiary" />
</button>
)}
</div>
{isUploading && (
<div className="w-full max-w-xs mx-auto">
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
<div className="h-full bg-accent-indigo transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-sm text-text-tertiary"> {progress}%</p>
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
<div className="h-full bg-purple-400 rounded-full transition-all duration-300" style={{ width: `${progress}%` }} />
</div>
)}
{isUploading && (
<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>
) : (
<label className="cursor-pointer">
<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"> MP4MOVAVI 500MB</p>
<input type="file" accept="video/*" onChange={handleFileChange} className="hidden" />
</label>
)}
</div>
</div>
)}
<Button onClick={handleUpload} disabled={!file || isUploading} fullWidth>
{isUploading ? '上传中...' : '提交视频'}
{isUploading ? (
<><Loader2 size={16} className="animate-spin" /> {progress}%</>
) : '提交视频'}
</Button>
</CardContent>
</Card>
@ -318,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

@ -21,7 +21,8 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
const USER_STORAGE_KEY = 'miaosi_user'
// 开发模式:使用 mock 数据
export const USE_MOCK = process.env.NEXT_PUBLIC_USE_MOCK === 'true' || process.env.NODE_ENV === 'development'
export const USE_MOCK = process.env.NEXT_PUBLIC_USE_MOCK === 'true' ||
(process.env.NEXT_PUBLIC_USE_MOCK !== 'false' && process.env.NODE_ENV === 'development')
// Mock 用户数据
const MOCK_USERS: Record<string, User & { password: string }> = {
@ -74,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()
@ -108,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 }
@ -137,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

@ -53,47 +53,12 @@ export function useOSSUpload(fileType: string = 'general'): UseOSSUploadReturn {
return result
}
// 1. 获取上传凭证
setProgress(10)
const policy = await api.getUploadPolicy(fileType)
// 2. 构建 TOS 直传 FormData
const fileKey = `${policy.dir}${Date.now()}_${file.name}`
const formData = new FormData()
formData.append('key', fileKey)
formData.append('x-tos-algorithm', policy.x_tos_algorithm)
formData.append('x-tos-credential', policy.x_tos_credential)
formData.append('x-tos-date', policy.x_tos_date)
formData.append('x-tos-signature', policy.x_tos_signature)
formData.append('policy', policy.policy)
formData.append('success_action_status', '200')
formData.append('file', file)
// 3. 上传到 TOS
setProgress(30)
const xhr = new XMLHttpRequest()
await new Promise<void>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
setProgress(30 + Math.round((e.loaded / e.total) * 50))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`上传失败: ${xhr.status}`))
}
}
xhr.onerror = () => reject(new Error('网络错误'))
xhr.open('POST', policy.host)
xhr.send(formData)
// 后端代理上传:文件 → 后端 → TOS避免浏览器 CORS/代理问题
setProgress(5)
const result = await api.proxyUpload(file, fileType, (pct) => {
setProgress(5 + Math.round(pct * 0.9))
})
// 4. 回调通知后端
setProgress(90)
const result = await api.fileUploaded(fileKey, file.name, file.size, fileType)
setProgress(100)
setIsUploading(false)
return {

View File

@ -0,0 +1,101 @@
/**
* 访 URL
*
* / TOS
* URL 5
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
const urlCache = new Map<string, { signedUrl: string; expireAt: number }>()
export function useSignedUrl(originalUrl: string | undefined | null) {
const [signedUrl, setSignedUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => { mountedRef.current = false }
}, [])
const fetchSignedUrl = useCallback(async () => {
if (!originalUrl) {
setSignedUrl(null)
return
}
// Mock 模式直接返回原始 URL
if (USE_MOCK) {
setSignedUrl(originalUrl)
return
}
// 非 TOS URL如外部链接直接返回
if (!originalUrl.includes('tos-cn-') && !originalUrl.includes('volces.com') && !originalUrl.startsWith('uploads/')) {
setSignedUrl(originalUrl)
return
}
// 检查缓存(提前 5 分钟过期)
const cached = urlCache.get(originalUrl)
if (cached && cached.expireAt > Date.now() + 5 * 60 * 1000) {
setSignedUrl(cached.signedUrl)
return
}
setLoading(true)
try {
const expireSeconds = 3600
const url = await api.getSignedUrl(originalUrl)
if (mountedRef.current) {
setSignedUrl(url)
urlCache.set(originalUrl, {
signedUrl: url,
expireAt: Date.now() + expireSeconds * 1000,
})
}
} catch {
// 签名失败时回退到原始 URL
if (mountedRef.current) {
setSignedUrl(originalUrl)
}
} finally {
if (mountedRef.current) {
setLoading(false)
}
}
}, [originalUrl])
useEffect(() => {
fetchSignedUrl()
}, [fetchSignedUrl])
return { signedUrl, loading, refresh: fetchSignedUrl }
}
/**
* URL
*/
export async function getSignedUrls(urls: string[]): Promise<Map<string, string>> {
const result = new Map<string, string>()
if (USE_MOCK) {
urls.forEach(u => result.set(u, u))
return result
}
await Promise.all(
urls.map(async (url) => {
try {
const signed = await api.getSignedUrl(url)
result.set(url, signed)
} catch {
result.set(url, url)
}
})
)
return result
}

View File

@ -453,6 +453,64 @@ class ApiClient {
return response.data
}
/**
* TOS CORS/
*/
async proxyUpload(file: File, fileType: string = 'general', onProgress?: (pct: number) => void): Promise<FileUploadedResponse> {
const formData = new FormData()
formData.append('file', file)
formData.append('file_type', fileType)
const response = await this.client.post<FileUploadedResponse>('/upload/proxy', formData, {
timeout: 300000,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total && onProgress) onProgress(Math.round((e.loaded / e.total) * 100))
},
})
return response.data
}
/**
* 访 URL
*/
async getSignedUrl(url: string): Promise<string> {
const response = await this.client.get<{ signed_url: string; expire_seconds: number }>(
'/upload/sign-url',
{ 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)
}
// ==================== 视频审核 ====================
/**
@ -496,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
}
@ -649,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
}
// ==================== 组织关系 ====================
/**
@ -813,6 +902,13 @@ class ApiClient {
return response.data
}
/**
*
*/
async deleteWhitelistItem(id: string): Promise<void> {
await this.client.delete(`/rules/whitelist/${id}`)
}
/**
*
*/
@ -866,7 +962,9 @@ class ApiClient {
* AI
*/
async parsePlatformRule(data: PlatformRuleParseRequest): Promise<PlatformRuleParseResponse> {
const response = await this.client.post<PlatformRuleParseResponse>('/rules/platform-rules/parse', data)
const response = await this.client.post<PlatformRuleParseResponse>('/rules/platform-rules/parse', data, {
timeout: 180000, // 3 分钟,视觉模型解析图片 PDF 较慢
})
return response.data
}

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
@ -34,6 +36,7 @@ export interface BriefResponse {
max_duration?: number | null
other_requirements?: string | null
attachments?: BriefAttachment[] | null
agency_attachments?: BriefAttachment[] | null
created_at: string
updated_at: string
}
@ -49,4 +52,5 @@ export interface BriefCreateRequest {
max_duration?: number
other_requirements?: string
attachments?: BriefAttachment[]
agency_attachments?: BriefAttachment[]
}

View File

@ -13,6 +13,7 @@ export interface ProjectResponse {
id: string
name: string
description?: string | null
platform?: string | null
brand_id: string
brand_name?: string | null
status: string
@ -34,6 +35,7 @@ export interface ProjectListResponse {
export interface ProjectCreateRequest {
name: string
description?: string
platform?: string
start_date?: string
deadline?: string
agency_ids?: string[]
@ -42,6 +44,7 @@ export interface ProjectCreateRequest {
export interface ProjectUpdateRequest {
name?: string
description?: string
platform?: string
start_date?: string
deadline?: string
status?: 'active' | 'completed' | 'archived'

View File

@ -29,6 +29,7 @@ export interface ProjectInfo {
id: string
name: string
brand_name?: string | null
platform?: string | null
}
export interface AgencyInfo {
@ -42,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
}>
@ -58,7 +99,6 @@ export interface AIReviewResult {
content: string
suggestion: string
}>
summary?: string
}
// 任务响应(对应后端 TaskResponse

View File

@ -7,7 +7,6 @@
"x": -271,
"y": -494,
"name": "达人端桌面 - 任务列表",
"enabled": false,
"clip": true,
"width": 1440,
"height": 4300,
@ -9615,7 +9614,6 @@
"x": 3080,
"y": 5772,
"name": "达人端桌面 - 视频阶段/上传视频",
"enabled": false,
"clip": true,
"width": 1440,
"height": 900,
@ -10447,7 +10445,6 @@
"x": -1477,
"y": 4300,
"name": "达人端桌面 - 消息中心",
"enabled": false,
"width": 1440,
"height": 2400,
"fill": "$--bg-page",
@ -14314,7 +14311,6 @@
"x": 0,
"y": 5400,
"name": "达人端桌面 - 个人中心",
"enabled": false,
"clip": true,
"width": 1440,
"height": 900,
@ -28098,7 +28094,6 @@
"x": 0,
"y": 13100,
"name": "代理商端 - 达人管理",
"enabled": false,
"clip": true,
"width": 1440,
"height": 900,