feat: AI 审核自动驳回 + 功效词可配置 + UI 修复
- AI 自动驳回:法规/品牌安全 HIGH 违规或总分<40 自动打回上传阶段 - 功效词可配置:从硬编码改为品牌方在规则页面自行管理 - 驳回通知:AI 驳回时只通知达人,含具体原因 - 达人端:脚本/视频页面展示 AI 驳回原因 + 重新上传入口 - 规则页面:新增"功效词"分类 - 种子数据:新增 6 条默认功效词 - 其他:代理商管理下拉修复、AI 配置模型列表扩展、视觉模型标签修正、规则编辑放开限制 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0ef7650c09
commit
0b3dfa3c52
@ -38,8 +38,8 @@ router = APIRouter(prefix="/scripts", tags=["scripts"])
|
|||||||
# 内置违禁词库(广告极限词)
|
# 内置违禁词库(广告极限词)
|
||||||
ABSOLUTE_WORDS = ["最好", "第一", "最佳", "绝对", "100%"]
|
ABSOLUTE_WORDS = ["最好", "第一", "最佳", "绝对", "100%"]
|
||||||
|
|
||||||
# 功效词库(医疗/功效宣称)
|
# 默认功效词库(品牌方未配置时的兜底)
|
||||||
EFFICACY_WORDS = ["根治", "治愈", "治疗", "药效", "疗效", "特效"]
|
DEFAULT_EFFICACY_WORDS = ["根治", "治愈", "治疗", "药效", "疗效", "特效"]
|
||||||
|
|
||||||
# 广告语境关键词(用于判断是否为广告场景)
|
# 广告语境关键词(用于判断是否为广告场景)
|
||||||
AD_CONTEXT_KEYWORDS = ["产品", "购买", "销量", "品质", "推荐", "价格", "优惠", "促销"]
|
AD_CONTEXT_KEYWORDS = ["产品", "购买", "销量", "品质", "推荐", "价格", "优惠", "促销"]
|
||||||
@ -282,7 +282,12 @@ async def review_script(
|
|||||||
|
|
||||||
# 获取品牌方配置的所有规则数据
|
# 获取品牌方配置的所有规则数据
|
||||||
whitelist = await get_whitelist_for_brand(x_tenant_id, request.brand_id, db)
|
whitelist = await get_whitelist_for_brand(x_tenant_id, request.brand_id, db)
|
||||||
tenant_forbidden_words = await get_forbidden_words_for_tenant(x_tenant_id, db)
|
all_tenant_words = await get_forbidden_words_for_tenant(x_tenant_id, db)
|
||||||
|
# 分离功效词和普通违禁词
|
||||||
|
efficacy_words = [w["word"] for w in all_tenant_words if w.get("category") == "功效词"]
|
||||||
|
if not efficacy_words:
|
||||||
|
efficacy_words = list(DEFAULT_EFFICACY_WORDS)
|
||||||
|
tenant_forbidden_words = [w for w in all_tenant_words if w.get("category") != "功效词"]
|
||||||
competitors = await get_competitors_for_brand(x_tenant_id, request.brand_id, db)
|
competitors = await get_competitors_for_brand(x_tenant_id, request.brand_id, db)
|
||||||
db_platform_rules = await get_active_platform_rules(
|
db_platform_rules = await get_active_platform_rules(
|
||||||
x_tenant_id, request.brand_id, request.platform.value, db,
|
x_tenant_id, request.brand_id, request.platform.value, db,
|
||||||
@ -310,8 +315,8 @@ async def review_script(
|
|||||||
))
|
))
|
||||||
start = pos + 1
|
start = pos + 1
|
||||||
|
|
||||||
# 1b. 功效词检测
|
# 1b. 功效词检测(从品牌方配置加载,未配置则用默认列表)
|
||||||
for word in EFFICACY_WORDS:
|
for word in efficacy_words:
|
||||||
if word in whitelist:
|
if word in whitelist:
|
||||||
continue
|
continue
|
||||||
start = 0
|
start = 0
|
||||||
@ -372,7 +377,7 @@ async def review_script(
|
|||||||
start = pos + 1
|
start = pos + 1
|
||||||
|
|
||||||
# ===== Step 2: 平台规则检测 (platform) =====
|
# ===== Step 2: 平台规则检测 (platform) =====
|
||||||
already_checked = set(ABSOLUTE_WORDS + [w["word"] for w in tenant_forbidden_words])
|
already_checked = set(ABSOLUTE_WORDS + efficacy_words + [w["word"] for w in tenant_forbidden_words])
|
||||||
platform_forbidden_words: list[str] = []
|
platform_forbidden_words: list[str] = []
|
||||||
platform_restricted_words: list[dict] = []
|
platform_restricted_words: list[dict] = []
|
||||||
platform_content_requirements: list[str] = []
|
platform_content_requirements: list[str] = []
|
||||||
|
|||||||
@ -132,53 +132,80 @@ async def _run_script_ai_review(task_id: str, tenant_id: str):
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info(f"任务 {task_id} AI 审核完成,得分: {result.score}")
|
ai_auto_rejected = task.script_ai_result and task.script_ai_result.get("ai_auto_rejected")
|
||||||
|
logger.info(f"任务 {task_id} AI 审核完成,得分: {result.score},自动驳回: {ai_auto_rejected}")
|
||||||
|
|
||||||
# SSE 通知达人和代理商
|
if ai_auto_rejected:
|
||||||
try:
|
# AI 自动驳回:只通知达人
|
||||||
user_ids = []
|
try:
|
||||||
creator_result = await db.execute(
|
creator_result = await db.execute(
|
||||||
select(Creator).where(Creator.id == task.creator_id)
|
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:
|
creator_obj = creator_result.scalar_one_or_none()
|
||||||
pass
|
if creator_obj:
|
||||||
|
reject_reason = task.script_ai_result.get("ai_reject_reason", "")
|
||||||
# 创建消息通知代理商
|
await notify_task_updated(
|
||||||
try:
|
task_id=task.id,
|
||||||
ag_result = await db.execute(
|
user_ids=[creator_obj.user_id],
|
||||||
select(Agency).where(Agency.id == task.agency_id)
|
data={"action": "ai_auto_rejected", "stage": task.stage.value, "score": result.score},
|
||||||
)
|
)
|
||||||
ag_obj = ag_result.scalar_one_or_none()
|
await create_message(
|
||||||
if ag_obj:
|
db=db,
|
||||||
await create_message(
|
user_id=creator_obj.user_id,
|
||||||
db=db,
|
type="task",
|
||||||
user_id=ag_obj.user_id,
|
title="脚本未通过 AI 审核",
|
||||||
type="task",
|
content=f"任务「{task.name}」未通过 AI 审核({result.score} 分),原因:{reject_reason}。请修改后重新上传。",
|
||||||
title="脚本 AI 审核完成",
|
related_task_id=task.id,
|
||||||
content=f"任务「{task.name}」AI 审核完成,综合得分 {result.score} 分,请审核。",
|
sender_name="系统",
|
||||||
related_task_id=task.id,
|
)
|
||||||
sender_name="系统",
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# 正常通过:SSE 通知达人和代理商 + 消息通知代理商
|
||||||
|
try:
|
||||||
|
user_ids = []
|
||||||
|
creator_result = await db.execute(
|
||||||
|
select(Creator).where(Creator.id == task.creator_id)
|
||||||
)
|
)
|
||||||
await db.commit()
|
creator_obj = creator_result.scalar_one_or_none()
|
||||||
except Exception:
|
if creator_obj:
|
||||||
pass
|
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 未配置时通知品牌方
|
# AI 未配置时通知品牌方
|
||||||
if not result.ai_available:
|
if not result.ai_available:
|
||||||
@ -307,53 +334,80 @@ async def _run_video_ai_review(task_id: str, tenant_id: str):
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info(f"任务 {task_id} 视频 AI 审核完成,得分: {video_score}")
|
ai_auto_rejected = task.video_ai_result and task.video_ai_result.get("ai_auto_rejected")
|
||||||
|
logger.info(f"任务 {task_id} 视频 AI 审核完成,得分: {video_score},自动驳回: {ai_auto_rejected}")
|
||||||
|
|
||||||
# SSE 通知
|
if ai_auto_rejected:
|
||||||
try:
|
# AI 自动驳回:只通知达人
|
||||||
user_ids = []
|
try:
|
||||||
creator_result = await db.execute(
|
creator_result = await db.execute(
|
||||||
select(Creator).where(Creator.id == task.creator_id)
|
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:
|
creator_obj = creator_result.scalar_one_or_none()
|
||||||
pass
|
if creator_obj:
|
||||||
|
reject_reason = task.video_ai_result.get("ai_reject_reason", "")
|
||||||
# 创建消息通知代理商
|
await notify_task_updated(
|
||||||
try:
|
task_id=task.id,
|
||||||
ag_result = await db.execute(
|
user_ids=[creator_obj.user_id],
|
||||||
select(Agency).where(Agency.id == task.agency_id)
|
data={"action": "ai_auto_rejected", "stage": task.stage.value, "score": video_score},
|
||||||
)
|
)
|
||||||
ag_obj = ag_result.scalar_one_or_none()
|
await create_message(
|
||||||
if ag_obj:
|
db=db,
|
||||||
await create_message(
|
user_id=creator_obj.user_id,
|
||||||
db=db,
|
type="task",
|
||||||
user_id=ag_obj.user_id,
|
title="视频未通过 AI 审核",
|
||||||
type="task",
|
content=f"任务「{task.name}」视频未通过 AI 审核({video_score} 分),原因:{reject_reason}。请修改后重新上传。",
|
||||||
title="视频 AI 审核完成",
|
related_task_id=task.id,
|
||||||
content=f"任务「{task.name}」视频 AI 审核完成,得分 {video_score} 分,请审核。",
|
sender_name="系统",
|
||||||
related_task_id=task.id,
|
)
|
||||||
sender_name="系统",
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# 正常通过:SSE 通知达人和代理商 + 消息通知代理商
|
||||||
|
try:
|
||||||
|
user_ids = []
|
||||||
|
creator_result = await db.execute(
|
||||||
|
select(Creator).where(Creator.id == task.creator_id)
|
||||||
)
|
)
|
||||||
await db.commit()
|
creator_obj = creator_result.scalar_one_or_none()
|
||||||
except Exception:
|
if creator_obj:
|
||||||
pass
|
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 未配置时通知品牌方
|
# AI 未配置时通知品牌方
|
||||||
if not result.ai_available:
|
if not result.ai_available:
|
||||||
|
|||||||
@ -195,6 +195,42 @@ async def upload_video(
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
AI_AUTO_REJECT_SCORE = 40
|
||||||
|
|
||||||
|
|
||||||
|
def _check_ai_auto_reject(score: int, result: dict) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
判断 AI 审核结果是否应自动驳回
|
||||||
|
|
||||||
|
触发条件(任一):
|
||||||
|
1. 法规合规维度存在 HIGH 级违规(违禁词/功效词)
|
||||||
|
2. 品牌安全维度存在 HIGH 级违规(竞品提及)
|
||||||
|
3. 总分 < 40
|
||||||
|
"""
|
||||||
|
reasons = []
|
||||||
|
violations = result.get("violations", [])
|
||||||
|
|
||||||
|
# 条件1: 法规 HIGH
|
||||||
|
high_legal = [v for v in violations if v.get("dimension") == "legal" and v.get("severity") == "high"]
|
||||||
|
if high_legal:
|
||||||
|
words = [v.get("content", "") for v in high_legal[:5]]
|
||||||
|
reasons.append(f"法规违规:{', '.join(words)}")
|
||||||
|
|
||||||
|
# 条件2: 品牌安全 HIGH
|
||||||
|
high_brand = [v for v in violations if v.get("dimension") == "brand_safety" and v.get("severity") == "high"]
|
||||||
|
if high_brand:
|
||||||
|
words = [v.get("content", "") for v in high_brand[:5]]
|
||||||
|
reasons.append(f"品牌安全违规:{', '.join(words)}")
|
||||||
|
|
||||||
|
# 条件3: 总分过低
|
||||||
|
if score < AI_AUTO_REJECT_SCORE:
|
||||||
|
reasons.append(f"综合评分 {score} 分,低于合格线 {AI_AUTO_REJECT_SCORE} 分")
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return True, ";".join(reasons)
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
async def complete_ai_review(
|
async def complete_ai_review(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
task: Task,
|
task: Task,
|
||||||
@ -206,9 +242,16 @@ async def complete_ai_review(
|
|||||||
完成 AI 审核
|
完成 AI 审核
|
||||||
|
|
||||||
- 更新 AI 审核结果
|
- 更新 AI 审核结果
|
||||||
- 状态流转到代理商审核
|
- 自动驳回:法规/品牌安全 HIGH 违规或总分 < 40 → 回到上传阶段
|
||||||
|
- 正常:流转到代理商审核
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
auto_rejected, reject_reason = _check_ai_auto_reject(score, result)
|
||||||
|
|
||||||
|
# 将自动驳回信息写入 result,前端可据此展示
|
||||||
|
if auto_rejected:
|
||||||
|
result["ai_auto_rejected"] = True
|
||||||
|
result["ai_reject_reason"] = reject_reason
|
||||||
|
|
||||||
if review_type == "script":
|
if review_type == "script":
|
||||||
if task.stage != TaskStage.SCRIPT_AI_REVIEW:
|
if task.stage != TaskStage.SCRIPT_AI_REVIEW:
|
||||||
@ -217,7 +260,11 @@ async def complete_ai_review(
|
|||||||
task.script_ai_score = score
|
task.script_ai_score = score
|
||||||
task.script_ai_result = result
|
task.script_ai_result = result
|
||||||
task.script_ai_reviewed_at = now
|
task.script_ai_reviewed_at = now
|
||||||
task.stage = TaskStage.SCRIPT_AGENCY_REVIEW
|
|
||||||
|
if auto_rejected:
|
||||||
|
task.stage = TaskStage.SCRIPT_UPLOAD
|
||||||
|
else:
|
||||||
|
task.stage = TaskStage.SCRIPT_AGENCY_REVIEW
|
||||||
|
|
||||||
elif review_type == "video":
|
elif review_type == "video":
|
||||||
if task.stage != TaskStage.VIDEO_AI_REVIEW:
|
if task.stage != TaskStage.VIDEO_AI_REVIEW:
|
||||||
@ -226,7 +273,11 @@ async def complete_ai_review(
|
|||||||
task.video_ai_score = score
|
task.video_ai_score = score
|
||||||
task.video_ai_result = result
|
task.video_ai_result = result
|
||||||
task.video_ai_reviewed_at = now
|
task.video_ai_reviewed_at = now
|
||||||
task.stage = TaskStage.VIDEO_AGENCY_REVIEW
|
|
||||||
|
if auto_rejected:
|
||||||
|
task.stage = TaskStage.VIDEO_UPLOAD
|
||||||
|
else:
|
||||||
|
task.stage = TaskStage.VIDEO_AGENCY_REVIEW
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"不支持的审核类型: {review_type}")
|
raise ValueError(f"不支持的审核类型: {review_type}")
|
||||||
|
|||||||
@ -403,10 +403,17 @@ async def seed_data() -> None:
|
|||||||
ForbiddenWord(id="FW100003", tenant_id=TENANT_ID, word="最好", category="绝对化用语", severity="medium"),
|
ForbiddenWord(id="FW100003", tenant_id=TENANT_ID, word="最好", category="绝对化用语", severity="medium"),
|
||||||
ForbiddenWord(id="FW100004", tenant_id=TENANT_ID, word="第一", category="绝对化用语", severity="medium"),
|
ForbiddenWord(id="FW100004", tenant_id=TENANT_ID, word="第一", category="绝对化用语", severity="medium"),
|
||||||
ForbiddenWord(id="FW100005", tenant_id=TENANT_ID, word="纯天然", category="虚假宣传", severity="medium"),
|
ForbiddenWord(id="FW100005", tenant_id=TENANT_ID, word="纯天然", category="虚假宣传", severity="medium"),
|
||||||
|
# 功效词(品牌方可自行增删)
|
||||||
|
ForbiddenWord(id="FW100006", tenant_id=TENANT_ID, word="根治", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100007", tenant_id=TENANT_ID, word="治愈", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100008", tenant_id=TENANT_ID, word="治疗", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100009", tenant_id=TENANT_ID, word="药效", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100010", tenant_id=TENANT_ID, word="疗效", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100011", tenant_id=TENANT_ID, word="特效", category="功效词", severity="high"),
|
||||||
]
|
]
|
||||||
db.add_all(forbidden_words)
|
db.add_all(forbidden_words)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
print(" ✓ 违禁词已创建: 5 条")
|
print(" ✓ 违禁词已创建: 11 条(含 6 条功效词)")
|
||||||
|
|
||||||
competitors = [
|
competitors = [
|
||||||
Competitor(id="CP100001", tenant_id=TENANT_ID, brand_id=BRAND_ID, name="安耐晒", keywords=["安耐晒", "ANESSA", "资生堂防晒"]),
|
Competitor(id="CP100001", tenant_id=TENANT_ID, brand_id=BRAND_ID, name="安耐晒", keywords=["安耐晒", "ANESSA", "资生堂防晒"]),
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Card, CardContent } from '@/components/ui/Card'
|
import { Card, CardContent } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
@ -69,6 +69,30 @@ export default function AgenciesManagePage() {
|
|||||||
|
|
||||||
// 操作菜单状态
|
// 操作菜单状态
|
||||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||||
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleToggleMenu = (agencyId: string, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (openMenuId === agencyId) {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
setMenuPos({ top: rect.bottom + 4, left: rect.right - 160 }) // 160 = menu width
|
||||||
|
setOpenMenuId(agencyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭菜单
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openMenuId) return
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [openMenuId])
|
||||||
|
|
||||||
// 删除确认弹窗状态
|
// 删除确认弹窗状态
|
||||||
const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null })
|
const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null })
|
||||||
@ -351,35 +375,13 @@ export default function AgenciesManagePage() {
|
|||||||
<StatusTag forcePass={agency.force_pass_enabled} />
|
<StatusTag forcePass={agency.force_pass_enabled} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="relative">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={(e) => handleToggleMenu(agency.id, e)}
|
||||||
onClick={() => setOpenMenuId(openMenuId === agency.id ? null : agency.id)}
|
>
|
||||||
>
|
<MoreVertical size={16} />
|
||||||
<MoreVertical size={16} />
|
</Button>
|
||||||
</Button>
|
|
||||||
{openMenuId === agency.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={() => handleOpenAssign(agency)}
|
|
||||||
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(agency)}
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -396,6 +398,38 @@ export default function AgenciesManagePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 操作菜单(fixed 定位,不受 overflow 裁剪) */}
|
||||||
|
{openMenuId && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-50 overflow-hidden"
|
||||||
|
style={{ top: menuPos.top, left: menuPos.left }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const agency = agencies.find(a => a.id === openMenuId)
|
||||||
|
if (agency) handleOpenAssign(agency)
|
||||||
|
}}
|
||||||
|
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={() => {
|
||||||
|
const agency = agencies.find(a => a.id === openMenuId)
|
||||||
|
if (agency) handleOpenDelete(agency)
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 邀请代理商弹窗 */}
|
{/* 邀请代理商弹窗 */}
|
||||||
<Modal isOpen={showInviteModal} onClose={handleCloseInviteModal} title="邀请代理商">
|
<Modal isOpen={showInviteModal} onClose={handleCloseInviteModal} title="邀请代理商">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -539,13 +573,6 @@ export default function AgenciesManagePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 点击其他地方关闭菜单 */}
|
|
||||||
{openMenuId && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-0"
|
|
||||||
onClick={() => setOpenMenuId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,21 +34,67 @@ const providerOptions: { value: AIProvider | string; label: string }[] = [
|
|||||||
{ value: 'moonshot', label: 'Moonshot' },
|
{ value: 'moonshot', label: 'Moonshot' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Mock 可用模型列表
|
// 预设可用模型列表
|
||||||
const mockModels: Record<string, ModelInfo[]> = {
|
const mockModels: Record<string, ModelInfo[]> = {
|
||||||
text: [
|
text: [
|
||||||
|
// Anthropic Claude
|
||||||
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||||||
|
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||||
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
||||||
|
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
|
||||||
|
// OpenAI
|
||||||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
|
||||||
|
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
|
||||||
|
{ id: 'o1', name: 'o1' },
|
||||||
|
{ id: 'o3-mini', name: 'o3-mini' },
|
||||||
|
// Google
|
||||||
|
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
|
||||||
|
{ id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' },
|
||||||
|
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
|
||||||
|
// DeepSeek
|
||||||
|
{ id: 'deepseek-chat', name: 'DeepSeek V3' },
|
||||||
|
{ id: 'deepseek-reasoner', name: 'DeepSeek R1' },
|
||||||
|
// 通义千问
|
||||||
|
{ id: 'qwen-max', name: '通义千问 Max' },
|
||||||
|
{ id: 'qwen-plus', name: '通义千问 Plus' },
|
||||||
|
{ id: 'qwen-turbo', name: '通义千问 Turbo' },
|
||||||
|
// 豆包
|
||||||
|
{ id: 'doubao-pro-256k', name: '豆包 Pro 256K' },
|
||||||
|
{ id: 'doubao-pro-32k', name: '豆包 Pro 32K' },
|
||||||
|
// 智谱
|
||||||
|
{ id: 'glm-4-plus', name: 'GLM-4 Plus' },
|
||||||
|
{ id: 'glm-4', name: 'GLM-4' },
|
||||||
|
// Moonshot
|
||||||
|
{ id: 'moonshot-v1-128k', name: 'Moonshot V1 128K' },
|
||||||
|
{ id: 'moonshot-v1-32k', name: 'Moonshot V1 32K' },
|
||||||
],
|
],
|
||||||
vision: [
|
vision: [
|
||||||
|
// Anthropic Claude (原生多模态)
|
||||||
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||||||
|
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||||
|
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
||||||
|
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
|
||||||
|
// OpenAI
|
||||||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||||
|
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
|
||||||
|
// Google
|
||||||
|
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
|
||||||
|
{ id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' },
|
||||||
|
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
|
||||||
|
// 通义千问 VL
|
||||||
|
{ id: 'qwen-vl-max', name: '通义千问 VL Max' },
|
||||||
|
{ id: 'qwen-vl-plus', name: '通义千问 VL Plus' },
|
||||||
|
// 智谱
|
||||||
|
{ id: 'glm-4v-plus', name: 'GLM-4V Plus' },
|
||||||
|
{ id: 'glm-4v', name: 'GLM-4V' },
|
||||||
],
|
],
|
||||||
audio: [
|
audio: [
|
||||||
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
|
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
|
||||||
|
{ id: 'whisper-large-v3-turbo', name: 'Whisper Large V3 Turbo' },
|
||||||
{ id: 'whisper-medium', name: 'Whisper Medium' },
|
{ id: 'whisper-medium', name: 'Whisper Medium' },
|
||||||
|
{ id: 'sensevoice-v1', name: 'SenseVoice V1 (阿里)' },
|
||||||
|
{ id: 'paraformer-realtime-v2', name: 'Paraformer V2 (阿里)' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +129,9 @@ export default function AIConfigPage() {
|
|||||||
const [maxTokens, setMaxTokens] = useState(2000)
|
const [maxTokens, setMaxTokens] = useState(2000)
|
||||||
|
|
||||||
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo[]>>(mockModels)
|
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo[]>>(mockModels)
|
||||||
|
const [customLlmModel, setCustomLlmModel] = useState('')
|
||||||
|
const [customVisionModel, setCustomVisionModel] = useState('')
|
||||||
|
const [customAsrModel, setCustomAsrModel] = useState('')
|
||||||
|
|
||||||
const [testResults, setTestResults] = useState<Record<string, { status: TestStatus; latency?: number; error?: string }>>({
|
const [testResults, setTestResults] = useState<Record<string, { status: TestStatus; latency?: number; error?: string }>>({
|
||||||
text: { status: 'idle' },
|
text: { status: 'idle' },
|
||||||
@ -101,9 +150,26 @@ export default function AIConfigPage() {
|
|||||||
setBaseUrl(config.base_url)
|
setBaseUrl(config.base_url)
|
||||||
setApiKey('') // API key is masked, don't fill it
|
setApiKey('') // API key is masked, don't fill it
|
||||||
setIsConfigured(config.is_configured)
|
setIsConfigured(config.is_configured)
|
||||||
setLlmModel(config.models.text)
|
const models = availableModels
|
||||||
setVisionModel(config.models.vision)
|
// 如果后端返回的模型不在预设列表中,设为自定义
|
||||||
setAsrModel(config.models.audio)
|
if (config.models.text && !(models.text || []).some(m => m.id === config.models.text)) {
|
||||||
|
setLlmModel('__custom__')
|
||||||
|
setCustomLlmModel(config.models.text)
|
||||||
|
} else {
|
||||||
|
setLlmModel(config.models.text)
|
||||||
|
}
|
||||||
|
if (config.models.vision && !(models.vision || []).some(m => m.id === config.models.vision)) {
|
||||||
|
setVisionModel('__custom__')
|
||||||
|
setCustomVisionModel(config.models.vision)
|
||||||
|
} else {
|
||||||
|
setVisionModel(config.models.vision)
|
||||||
|
}
|
||||||
|
if (config.models.audio && !(models.audio || []).some(m => m.id === config.models.audio)) {
|
||||||
|
setAsrModel('__custom__')
|
||||||
|
setCustomAsrModel(config.models.audio)
|
||||||
|
} else {
|
||||||
|
setAsrModel(config.models.audio)
|
||||||
|
}
|
||||||
setTemperature(config.parameters.temperature)
|
setTemperature(config.parameters.temperature)
|
||||||
setMaxTokens(config.parameters.max_tokens)
|
setMaxTokens(config.parameters.max_tokens)
|
||||||
if (config.available_models && Object.keys(config.available_models).length > 0) {
|
if (config.available_models && Object.keys(config.available_models).length > 0) {
|
||||||
@ -126,6 +192,10 @@ export default function AIConfigPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadConfig() }, [loadConfig])
|
useEffect(() => { loadConfig() }, [loadConfig])
|
||||||
|
|
||||||
|
// 获取实际使用的模型 ID(自定义时用输入值)
|
||||||
|
const getActualModel = (selected: string, custom: string) =>
|
||||||
|
selected === '__custom__' ? custom : selected
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
setTestResults({
|
setTestResults({
|
||||||
text: { status: 'testing' },
|
text: { status: 'testing' },
|
||||||
@ -148,7 +218,7 @@ export default function AIConfigPage() {
|
|||||||
provider: provider as AIProvider,
|
provider: provider as AIProvider,
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
api_key: apiKey || '***', // use existing key if not changed
|
api_key: apiKey || '***', // use existing key if not changed
|
||||||
models: { text: llmModel, vision: visionModel, audio: asrModel },
|
models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) },
|
||||||
})
|
})
|
||||||
const newResults: Record<string, { status: TestStatus; latency?: number; error?: string }> = {}
|
const newResults: Record<string, { status: TestStatus; latency?: number; error?: string }> = {}
|
||||||
for (const [key, r] of Object.entries(result.results)) {
|
for (const [key, r] of Object.entries(result.results)) {
|
||||||
@ -184,7 +254,7 @@ export default function AIConfigPage() {
|
|||||||
provider: provider as AIProvider,
|
provider: provider as AIProvider,
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
api_key: apiKey || '***',
|
api_key: apiKey || '***',
|
||||||
models: { text: llmModel, vision: visionModel, audio: asrModel },
|
models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) },
|
||||||
parameters: { temperature, max_tokens: maxTokens },
|
parameters: { temperature, max_tokens: maxTokens },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -304,32 +374,52 @@ export default function AIConfigPage() {
|
|||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
value={llmModel}
|
value={llmModel}
|
||||||
onChange={(e) => setLlmModel(e.target.value)}
|
onChange={(e) => { setLlmModel(e.target.value); if (e.target.value !== '__custom__') setCustomLlmModel('') }}
|
||||||
>
|
>
|
||||||
{(availableModels.text || []).map(model => (
|
{(availableModels.text || []).map(model => (
|
||||||
<option key={model.id} value={model.id}>{model.name}</option>
|
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
|
||||||
))}
|
))}
|
||||||
|
<option value="__custom__">自定义模型...</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-tertiary mt-2">用于 Brief 解析、语义分析、报告生成</p>
|
{llmModel === '__custom__' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
|
value={customLlmModel}
|
||||||
|
onChange={(e) => setCustomLlmModel(e.target.value)}
|
||||||
|
placeholder="输入模型 ID,如 deepseek-chat"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-text-tertiary mt-2">用于 Brief 解析、脚本语义审核、卖点匹配分析</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 视频分析模型 */}
|
{/* 视觉理解模型 */}
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Eye size={16} className="text-accent-green" />
|
<Eye size={16} className="text-accent-green" />
|
||||||
<span className="font-medium text-text-primary">视频分析模型 (Vision)</span>
|
<span className="font-medium text-text-primary">视觉理解模型 (Vision)</span>
|
||||||
{getTestStatusIcon('vision')}
|
{getTestStatusIcon('vision')}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
value={visionModel}
|
value={visionModel}
|
||||||
onChange={(e) => setVisionModel(e.target.value)}
|
onChange={(e) => { setVisionModel(e.target.value); if (e.target.value !== '__custom__') setCustomVisionModel('') }}
|
||||||
>
|
>
|
||||||
{(availableModels.vision || []).map(model => (
|
{(availableModels.vision || []).map(model => (
|
||||||
<option key={model.id} value={model.id}>{model.name}</option>
|
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
|
||||||
))}
|
))}
|
||||||
|
<option value="__custom__">自定义模型...</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-tertiary mt-2">用于画面语义分析、场景/风险识别</p>
|
{visionModel === '__custom__' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
|
value={customVisionModel}
|
||||||
|
onChange={(e) => setCustomVisionModel(e.target.value)}
|
||||||
|
placeholder="输入模型 ID,如 gpt-4o"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-text-tertiary mt-2">用于脚本文档中的图片审核(竞品 logo、违规画面识别)及视频帧分析</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 音频解析模型 */}
|
{/* 音频解析模型 */}
|
||||||
@ -342,12 +432,22 @@ export default function AIConfigPage() {
|
|||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
value={asrModel}
|
value={asrModel}
|
||||||
onChange={(e) => setAsrModel(e.target.value)}
|
onChange={(e) => { setAsrModel(e.target.value); if (e.target.value !== '__custom__') setCustomAsrModel('') }}
|
||||||
>
|
>
|
||||||
{(availableModels.audio || []).map(model => (
|
{(availableModels.audio || []).map(model => (
|
||||||
<option key={model.id} value={model.id}>{model.name}</option>
|
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
|
||||||
))}
|
))}
|
||||||
|
<option value="__custom__">自定义模型...</option>
|
||||||
</select>
|
</select>
|
||||||
|
{asrModel === '__custom__' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
|
value={customAsrModel}
|
||||||
|
onChange={(e) => setCustomAsrModel(e.target.value)}
|
||||||
|
placeholder="输入模型 ID,如 whisper-large-v3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p className="text-xs text-text-tertiary mt-2">用于语音转文字、口播内容提取</p>
|
<p className="text-xs text-text-tertiary mt-2">用于语音转文字、口播内容提取</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -119,6 +119,7 @@ const mockWhitelist: WhitelistResponse[] = [
|
|||||||
|
|
||||||
const categoryOptions = [
|
const categoryOptions = [
|
||||||
{ value: '极限词', label: '极限词' },
|
{ value: '极限词', label: '极限词' },
|
||||||
|
{ value: '功效词', label: '功效词' },
|
||||||
{ value: '虚假宣称', label: '虚假宣称' },
|
{ value: '虚假宣称', label: '虚假宣称' },
|
||||||
{ value: '价格欺诈', label: '价格欺诈' },
|
{ value: '价格欺诈', label: '价格欺诈' },
|
||||||
{ value: '平台规则', label: '平台规则' },
|
{ value: '平台规则', label: '平台规则' },
|
||||||
@ -720,9 +721,9 @@ export default function RulesPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => viewRuleDetail(rule)}
|
onClick={() => viewRuleDetail(rule)}
|
||||||
className="p-1.5 rounded-lg text-text-tertiary hover:text-accent-indigo hover:bg-accent-indigo/10 transition-colors"
|
className="p-1.5 rounded-lg text-text-tertiary hover:text-accent-indigo hover:bg-accent-indigo/10 transition-colors"
|
||||||
title={rule.status === 'draft' ? '确认规则' : '查看详情'}
|
title="编辑规则"
|
||||||
>
|
>
|
||||||
{rule.status === 'draft' ? <Edit3 size={16} /> : <Eye size={16} />}
|
<Edit3 size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1054,29 +1055,25 @@ export default function RulesPage() {
|
|||||||
{editingRules.forbidden_words.map((word, i) => (
|
{editingRules.forbidden_words.map((word, i) => (
|
||||||
<span key={i} className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-accent-coral/10 text-accent-coral text-sm border border-accent-coral/20">
|
<span key={i} className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-accent-coral/10 text-accent-coral text-sm border border-accent-coral/20">
|
||||||
{word}
|
{word}
|
||||||
{selectedRule.status === 'draft' && (
|
<button type="button" onClick={() => removeForbiddenWord(i)} className="hover:text-accent-coral/70">
|
||||||
<button type="button" onClick={() => removeForbiddenWord(i)} className="hover:text-accent-coral/70">
|
<X size={12} />
|
||||||
<X size={12} />
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{selectedRule.status === 'draft' && (
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={editingForbiddenInput}
|
||||||
value={editingForbiddenInput}
|
onChange={(e) => setEditingForbiddenInput(e.target.value)}
|
||||||
onChange={(e) => setEditingForbiddenInput(e.target.value)}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addForbiddenWord() } }}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addForbiddenWord() } }}
|
placeholder="添加违禁词..."
|
||||||
placeholder="添加违禁词..."
|
className="flex-1 px-3 py-1.5 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
||||||
className="flex-1 px-3 py-1.5 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
/>
|
||||||
/>
|
<Button size="sm" onClick={addForbiddenWord} disabled={!editingForbiddenInput.trim()}>
|
||||||
<Button size="sm" onClick={addForbiddenWord} disabled={!editingForbiddenInput.trim()}>
|
添加
|
||||||
添加
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 限制词 */}
|
{/* 限制词 */}
|
||||||
@ -1116,11 +1113,9 @@ export default function RulesPage() {
|
|||||||
<div key={i} className="flex items-center gap-2 p-2.5 rounded-lg bg-bg-elevated border border-border-subtle">
|
<div key={i} className="flex items-center gap-2 p-2.5 rounded-lg bg-bg-elevated border border-border-subtle">
|
||||||
<CheckCircle size={14} className="text-accent-green flex-shrink-0" />
|
<CheckCircle size={14} className="text-accent-green flex-shrink-0" />
|
||||||
<span className="text-sm text-text-primary flex-1">{req}</span>
|
<span className="text-sm text-text-primary flex-1">{req}</span>
|
||||||
{selectedRule.status === 'draft' && (
|
<button type="button" onClick={() => removeContentReq(i)} className="text-text-tertiary hover:text-accent-coral">
|
||||||
<button type="button" onClick={() => removeContentReq(i)} className="text-text-tertiary hover:text-accent-coral">
|
<X size={14} />
|
||||||
<X size={14} />
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -1145,14 +1140,8 @@ export default function RulesPage() {
|
|||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="flex gap-3 justify-end pt-2 border-t border-border-subtle">
|
<div className="flex gap-3 justify-end pt-2 border-t border-border-subtle">
|
||||||
<Button variant="ghost" onClick={() => { setShowDetailModal(false); setSelectedRule(null); setEditingRules(null) }}>
|
<Button variant="ghost" onClick={() => { setShowDetailModal(false); setSelectedRule(null); setEditingRules(null) }}>
|
||||||
{selectedRule.status === 'draft' ? '稍后确认' : '关闭'}
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
{selectedRule.status === 'draft' && (
|
|
||||||
<Button onClick={handleConfirmRule} disabled={submitting} loading={submitting}>
|
|
||||||
<CheckCircle size={16} />
|
|
||||||
确认生效
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{selectedRule.status === 'active' && (
|
{selectedRule.status === 'active' && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@ -1166,6 +1155,10 @@ export default function RulesPage() {
|
|||||||
重新上传
|
重新上传
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button onClick={handleConfirmRule} disabled={submitting} loading={submitting}>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
{selectedRule.status === 'draft' ? '确认生效' : '保存修改'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -35,6 +35,8 @@ type ScriptTaskUI = {
|
|||||||
brandName: string
|
brandName: string
|
||||||
scriptStatus: string
|
scriptStatus: string
|
||||||
scriptFile: string | null
|
scriptFile: string | null
|
||||||
|
aiAutoRejected?: boolean
|
||||||
|
aiRejectReason?: string
|
||||||
aiResult: null | {
|
aiResult: null | {
|
||||||
score: number
|
score: number
|
||||||
dimensions?: ReviewDimensions
|
dimensions?: ReviewDimensions
|
||||||
@ -56,8 +58,11 @@ type BriefUI = {
|
|||||||
function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI {
|
function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI {
|
||||||
const stage = task.stage
|
const stage = task.stage
|
||||||
let status = 'pending_upload'
|
let status = 'pending_upload'
|
||||||
|
const aiAutoRejected = task.script_ai_result?.ai_auto_rejected === true
|
||||||
switch (stage) {
|
switch (stage) {
|
||||||
case 'script_upload': status = 'pending_upload'; break
|
case 'script_upload':
|
||||||
|
status = aiAutoRejected ? 'ai_rejected' : 'pending_upload'
|
||||||
|
break
|
||||||
case 'script_ai_review': status = 'ai_reviewing'; break
|
case 'script_ai_review': status = 'ai_reviewing'; break
|
||||||
case 'script_agency_review': status = 'agent_reviewing'; break
|
case 'script_agency_review': status = 'agent_reviewing'; break
|
||||||
case 'script_brand_review': status = 'brand_reviewing'; break
|
case 'script_brand_review': status = 'brand_reviewing'; break
|
||||||
@ -99,6 +104,8 @@ function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI {
|
|||||||
brandName: task.project?.brand_name || '',
|
brandName: task.project?.brand_name || '',
|
||||||
scriptStatus: status,
|
scriptStatus: status,
|
||||||
scriptFile: task.script_file_name || null,
|
scriptFile: task.script_file_name || null,
|
||||||
|
aiAutoRejected,
|
||||||
|
aiRejectReason: task.script_ai_result?.ai_reject_reason,
|
||||||
aiResult,
|
aiResult,
|
||||||
agencyReview,
|
agencyReview,
|
||||||
brandReview,
|
brandReview,
|
||||||
@ -596,7 +603,7 @@ export default function CreatorScriptPage() {
|
|||||||
|
|
||||||
const getStatusDisplay = () => {
|
const getStatusDisplay = () => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
pending_upload: '待上传脚本', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',
|
pending_upload: '待上传脚本', ai_rejected: 'AI 审核未通过', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',
|
||||||
agent_reviewing: '代理商审核中', agent_rejected: '代理商驳回',
|
agent_reviewing: '代理商审核中', agent_rejected: '代理商驳回',
|
||||||
brand_reviewing: '品牌方终审中', brand_passed: '审核通过', brand_rejected: '品牌方驳回',
|
brand_reviewing: '品牌方终审中', brand_passed: '审核通过', brand_rejected: '品牌方驳回',
|
||||||
}
|
}
|
||||||
@ -622,6 +629,23 @@ export default function CreatorScriptPage() {
|
|||||||
<AgencyBriefSection toast={toast} briefData={briefData} />
|
<AgencyBriefSection toast={toast} briefData={briefData} />
|
||||||
|
|
||||||
{task.scriptStatus === 'pending_upload' && <UploadSection taskId={taskId} onUploaded={loadTask} />}
|
{task.scriptStatus === 'pending_upload' && <UploadSection taskId={taskId} onUploaded={loadTask} />}
|
||||||
|
{task.scriptStatus === 'ai_rejected' && (
|
||||||
|
<>
|
||||||
|
<Card className="border-accent-coral/30 bg-accent-coral/5">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<XCircle size={20} className="text-accent-coral mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-text-primary font-medium">AI 审核未通过,请修改后重新上传</p>
|
||||||
|
{task.aiRejectReason && <p className="text-sm text-text-secondary mt-1">{task.aiRejectReason}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<AIResultSection task={task} />
|
||||||
|
<UploadSection taskId={taskId} onUploaded={loadTask} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{task.scriptStatus === 'ai_reviewing' && <AIReviewingSection />}
|
{task.scriptStatus === 'ai_reviewing' && <AIReviewingSection />}
|
||||||
{task.scriptStatus === 'ai_result' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
|
{task.scriptStatus === 'ai_result' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
|
||||||
{task.scriptStatus === 'agent_reviewing' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
|
{task.scriptStatus === 'agent_reviewing' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
|
||||||
|
|||||||
@ -22,6 +22,8 @@ type VideoTaskUI = {
|
|||||||
brandName: string
|
brandName: string
|
||||||
videoStatus: string
|
videoStatus: string
|
||||||
videoFile: string | null
|
videoFile: string | null
|
||||||
|
aiAutoRejected?: boolean
|
||||||
|
aiRejectReason?: string
|
||||||
aiResult: null | {
|
aiResult: null | {
|
||||||
score: number
|
score: number
|
||||||
hardViolations: Array<{ type: string; content: string; timestamp: number; suggestion: string }>
|
hardViolations: Array<{ type: string; content: string; timestamp: number; suggestion: string }>
|
||||||
@ -36,8 +38,9 @@ type VideoTaskUI = {
|
|||||||
function mapApiToVideoUI(task: TaskResponse): VideoTaskUI {
|
function mapApiToVideoUI(task: TaskResponse): VideoTaskUI {
|
||||||
const stage = task.stage
|
const stage = task.stage
|
||||||
let status = 'pending_upload'
|
let status = 'pending_upload'
|
||||||
|
const aiAutoRejected = task.video_ai_result?.ai_auto_rejected === true
|
||||||
switch (stage) {
|
switch (stage) {
|
||||||
case 'video_upload': status = 'pending_upload'; break
|
case 'video_upload': status = aiAutoRejected ? 'ai_rejected' : 'pending_upload'; break
|
||||||
case 'video_ai_review': status = 'ai_reviewing'; break
|
case 'video_ai_review': status = 'ai_reviewing'; break
|
||||||
case 'video_agency_review': status = 'agent_reviewing'; break
|
case 'video_agency_review': status = 'agent_reviewing'; break
|
||||||
case 'video_brand_review': status = 'brand_reviewing'; break
|
case 'video_brand_review': status = 'brand_reviewing'; break
|
||||||
@ -80,6 +83,8 @@ function mapApiToVideoUI(task: TaskResponse): VideoTaskUI {
|
|||||||
brandName: task.project?.brand_name || '',
|
brandName: task.project?.brand_name || '',
|
||||||
videoStatus: status,
|
videoStatus: status,
|
||||||
videoFile: task.video_file_name || null,
|
videoFile: task.video_file_name || null,
|
||||||
|
aiAutoRejected,
|
||||||
|
aiRejectReason: task.video_ai_result?.ai_reject_reason,
|
||||||
aiResult,
|
aiResult,
|
||||||
agencyReview,
|
agencyReview,
|
||||||
brandReview,
|
brandReview,
|
||||||
@ -371,7 +376,7 @@ export default function CreatorVideoPage() {
|
|||||||
|
|
||||||
const getStatusDisplay = () => {
|
const getStatusDisplay = () => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
pending_upload: '待上传视频', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',
|
pending_upload: '待上传视频', ai_rejected: 'AI 审核未通过', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成',
|
||||||
agent_reviewing: '代理商审核中', agent_rejected: '代理商驳回',
|
agent_reviewing: '代理商审核中', agent_rejected: '代理商驳回',
|
||||||
brand_reviewing: '品牌方终审中', brand_passed: '审核通过', brand_rejected: '品牌方驳回',
|
brand_reviewing: '品牌方终审中', brand_passed: '审核通过', brand_rejected: '品牌方驳回',
|
||||||
}
|
}
|
||||||
@ -395,6 +400,23 @@ export default function CreatorVideoPage() {
|
|||||||
<Card><CardContent className="py-4"><ReviewSteps steps={getReviewSteps(task.videoStatus)} /></CardContent></Card>
|
<Card><CardContent className="py-4"><ReviewSteps steps={getReviewSteps(task.videoStatus)} /></CardContent></Card>
|
||||||
|
|
||||||
{task.videoStatus === 'pending_upload' && <UploadSection taskId={taskId} onUploaded={loadTask} />}
|
{task.videoStatus === 'pending_upload' && <UploadSection taskId={taskId} onUploaded={loadTask} />}
|
||||||
|
{task.videoStatus === 'ai_rejected' && (
|
||||||
|
<>
|
||||||
|
<Card className="border-accent-coral/30 bg-accent-coral/5">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<XCircle size={20} className="text-accent-coral mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-text-primary font-medium">视频 AI 审核未通过,请修改后重新上传</p>
|
||||||
|
{task.aiRejectReason && <p className="text-sm text-text-secondary mt-1">{task.aiRejectReason}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<AIResultSection task={task} />
|
||||||
|
<UploadSection taskId={taskId} onUploaded={loadTask} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{task.videoStatus === 'ai_reviewing' && <AIReviewingSection />}
|
{task.videoStatus === 'ai_reviewing' && <AIReviewingSection />}
|
||||||
{task.videoStatus === 'ai_result' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
|
{task.videoStatus === 'ai_result' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
|
||||||
{task.videoStatus === 'agent_reviewing' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
|
{task.videoStatus === 'agent_reviewing' && <><AIResultSection task={task} /><WaitingSection message="等待代理商审核" /></>}
|
||||||
|
|||||||
@ -85,6 +85,9 @@ export interface AIReviewResult {
|
|||||||
dimensions?: ReviewDimensions
|
dimensions?: ReviewDimensions
|
||||||
selling_point_matches?: SellingPointMatchResult[]
|
selling_point_matches?: SellingPointMatchResult[]
|
||||||
brief_match_detail?: BriefMatchDetail
|
brief_match_detail?: BriefMatchDetail
|
||||||
|
ai_auto_rejected?: boolean
|
||||||
|
ai_reject_reason?: string
|
||||||
|
ai_available?: boolean
|
||||||
violations: Array<{
|
violations: Array<{
|
||||||
type: string
|
type: string
|
||||||
content: string
|
content: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user