feat: Brief附件/项目平台/规则AI解析/消息中心修复 + 项目创建通知
- Brief 支持代理商附件上传 (迁移 007) - 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息 - 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题 - 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护 - 项目创建时自动发送消息通知 - .gitignore 排除 backend/data/ 数据库文件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
58aed5f201
commit
4c9b2f1263
6
.gitignore
vendored
6
.gitignore
vendored
@ -42,6 +42,12 @@ Thumbs.db
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Database data
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
venv/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@ -22,13 +22,15 @@ def upgrade() -> None:
|
|||||||
# 创建枚举类型
|
# 创建枚举类型
|
||||||
platform_enum = postgresql.ENUM(
|
platform_enum = postgresql.ENUM(
|
||||||
'douyin', 'xiaohongshu', 'bilibili', 'kuaishou',
|
'douyin', 'xiaohongshu', 'bilibili', 'kuaishou',
|
||||||
name='platform_enum'
|
name='platform_enum',
|
||||||
|
create_type=False,
|
||||||
)
|
)
|
||||||
platform_enum.create(op.get_bind(), checkfirst=True)
|
platform_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
task_status_enum = postgresql.ENUM(
|
task_status_enum = postgresql.ENUM(
|
||||||
'pending', 'processing', 'completed', 'failed', 'approved', 'rejected',
|
'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)
|
task_status_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
|||||||
@ -17,38 +17,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
# 原 manual_tasks 表已废弃,字段已合并到 003 的 tasks 表中
|
||||||
"manual_tasks",
|
pass
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_column("manual_tasks", "script_uploaded_at")
|
pass
|
||||||
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")
|
|
||||||
|
|||||||
@ -22,7 +22,8 @@ def upgrade() -> None:
|
|||||||
# 创建枚举类型
|
# 创建枚举类型
|
||||||
user_role_enum = postgresql.ENUM(
|
user_role_enum = postgresql.ENUM(
|
||||||
'brand', 'agency', 'creator',
|
'brand', 'agency', 'creator',
|
||||||
name='user_role_enum'
|
name='user_role_enum',
|
||||||
|
create_type=False,
|
||||||
)
|
)
|
||||||
user_role_enum.create(op.get_bind(), checkfirst=True)
|
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',
|
'script_upload', 'script_ai_review', 'script_agency_review', 'script_brand_review',
|
||||||
'video_upload', 'video_ai_review', 'video_agency_review', 'video_brand_review',
|
'video_upload', 'video_ai_review', 'video_agency_review', 'video_brand_review',
|
||||||
'completed', 'rejected',
|
'completed', 'rejected',
|
||||||
name='task_stage_enum'
|
name='task_stage_enum',
|
||||||
|
create_type=False,
|
||||||
)
|
)
|
||||||
task_stage_enum.create(op.get_bind(), checkfirst=True)
|
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(
|
op.create_table(
|
||||||
'users',
|
'users',
|
||||||
|
|||||||
26
backend/alembic/versions/007_add_brief_agency_attachments.py
Normal file
26
backend/alembic/versions/007_add_brief_agency_attachments.py
Normal 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')
|
||||||
26
backend/alembic/versions/008_add_project_platform.py
Normal file
26
backend/alembic/versions/008_add_project_platform.py
Normal 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')
|
||||||
@ -16,6 +16,7 @@ from app.api.deps import get_current_user
|
|||||||
from app.schemas.brief import (
|
from app.schemas.brief import (
|
||||||
BriefCreateRequest,
|
BriefCreateRequest,
|
||||||
BriefUpdateRequest,
|
BriefUpdateRequest,
|
||||||
|
AgencyBriefUpdateRequest,
|
||||||
BriefResponse,
|
BriefResponse,
|
||||||
)
|
)
|
||||||
from app.services.auth import generate_id
|
from app.services.auth import generate_id
|
||||||
@ -81,6 +82,7 @@ def _brief_to_response(brief: Brief) -> BriefResponse:
|
|||||||
max_duration=brief.max_duration,
|
max_duration=brief.max_duration,
|
||||||
other_requirements=brief.other_requirements,
|
other_requirements=brief.other_requirements,
|
||||||
attachments=brief.attachments,
|
attachments=brief.attachments,
|
||||||
|
agency_attachments=brief.agency_attachments,
|
||||||
created_at=brief.created_at,
|
created_at=brief.created_at,
|
||||||
updated_at=brief.updated_at,
|
updated_at=brief.updated_at,
|
||||||
)
|
)
|
||||||
@ -137,6 +139,7 @@ async def create_brief(
|
|||||||
max_duration=request.max_duration,
|
max_duration=request.max_duration,
|
||||||
other_requirements=request.other_requirements,
|
other_requirements=request.other_requirements,
|
||||||
attachments=request.attachments,
|
attachments=request.attachments,
|
||||||
|
agency_attachments=request.agency_attachments,
|
||||||
)
|
)
|
||||||
db.add(brief)
|
db.add(brief)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@ -180,3 +183,63 @@ async def update_brief(
|
|||||||
await db.refresh(brief)
|
await db.refresh(brief)
|
||||||
|
|
||||||
return _brief_to_response(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_attachments 字段,不能修改品牌方设置的其他 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 不存在")
|
||||||
|
|
||||||
|
# 仅更新 agency_attachments
|
||||||
|
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)
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from app.schemas.project import (
|
|||||||
AgencySummary,
|
AgencySummary,
|
||||||
)
|
)
|
||||||
from app.services.auth import generate_id
|
from app.services.auth import generate_id
|
||||||
|
from app.services.message_service import create_message
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["项目"])
|
router = APIRouter(prefix="/projects", tags=["项目"])
|
||||||
|
|
||||||
@ -46,6 +47,7 @@ async def _project_to_response(project: Project, db: AsyncSession) -> ProjectRes
|
|||||||
id=project.id,
|
id=project.id,
|
||||||
name=project.name,
|
name=project.name,
|
||||||
description=project.description,
|
description=project.description,
|
||||||
|
platform=project.platform,
|
||||||
brand_id=project.brand_id,
|
brand_id=project.brand_id,
|
||||||
brand_name=project.brand.name if project.brand else None,
|
brand_name=project.brand.name if project.brand else None,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
@ -72,6 +74,7 @@ async def create_project(
|
|||||||
brand_id=brand.id,
|
brand_id=brand.id,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
description=request.description,
|
description=request.description,
|
||||||
|
platform=request.platform,
|
||||||
start_date=request.start_date,
|
start_date=request.start_date,
|
||||||
deadline=request.deadline,
|
deadline=request.deadline,
|
||||||
status="active",
|
status="active",
|
||||||
@ -79,7 +82,7 @@ async def create_project(
|
|||||||
db.add(project)
|
db.add(project)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# 分配代理商
|
# 分配代理商(直接 INSERT 关联表,避免 async 懒加载问题)
|
||||||
if request.agency_ids:
|
if request.agency_ids:
|
||||||
for agency_id in request.agency_ids:
|
for agency_id in request.agency_ids:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -87,7 +90,12 @@ async def create_project(
|
|||||||
)
|
)
|
||||||
agency = result.scalar_one_or_none()
|
agency = result.scalar_one_or_none()
|
||||||
if agency:
|
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.flush()
|
||||||
|
|
||||||
await db.refresh(project)
|
await db.refresh(project)
|
||||||
@ -100,6 +108,21 @@ async def create_project(
|
|||||||
)
|
)
|
||||||
project = result.scalar_one()
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
return await _project_to_response(project, db)
|
return await _project_to_response(project, db)
|
||||||
|
|
||||||
|
|
||||||
@ -248,6 +271,8 @@ async def update_project(
|
|||||||
project.name = request.name
|
project.name = request.name
|
||||||
if request.description is not None:
|
if request.description is not None:
|
||||||
project.description = request.description
|
project.description = request.description
|
||||||
|
if request.platform is not None:
|
||||||
|
project.platform = request.platform
|
||||||
if request.start_date is not None:
|
if request.start_date is not None:
|
||||||
project.start_date = request.start_date
|
project.start_date = request.start_date
|
||||||
if request.deadline is not None:
|
if request.deadline is not None:
|
||||||
|
|||||||
@ -558,22 +558,40 @@ async def parse_platform_rule_document(
|
|||||||
"""
|
"""
|
||||||
await _ensure_tenant_exists(x_tenant_id, db)
|
await _ensure_tenant_exists(x_tenant_id, db)
|
||||||
|
|
||||||
# 1. 下载并解析文档
|
# 1. 尝试提取文本;对图片型 PDF 走视觉解析
|
||||||
|
document_text = ""
|
||||||
|
image_b64_list: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document_text = await DocumentParser.download_and_parse(
|
# 先检查是否为图片型 PDF
|
||||||
|
image_b64_list = await DocumentParser.download_and_get_images(
|
||||||
request.document_url, request.document_name,
|
request.document_url, request.document_name,
|
||||||
)
|
) or []
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"文档解析失败: {e}")
|
logger.warning(f"图片 PDF 检测失败,回退文本模式: {e}")
|
||||||
raise HTTPException(status_code=400, detail=f"文档下载或解析失败: {e}")
|
|
||||||
|
|
||||||
if not document_text.strip():
|
if not image_b64_list:
|
||||||
raise HTTPException(status_code=400, detail="文档内容为空,无法解析")
|
# 非图片 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 解析
|
if not document_text.strip():
|
||||||
parsed_rules = await _ai_parse_platform_rules(x_tenant_id, request.platform, document_text, db)
|
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)
|
# 3. 存入 DB (draft)
|
||||||
rule_id = f"pr-{uuid.uuid4().hex[:8]}"
|
rule_id = f"pr-{uuid.uuid4().hex[:8]}"
|
||||||
@ -757,7 +775,8 @@ async def _ai_parse_platform_rules(
|
|||||||
- duration: 视频时长要求,如果文档未提及则为 null
|
- duration: 视频时长要求,如果文档未提及则为 null
|
||||||
- content_requirements: 内容上的硬性要求
|
- content_requirements: 内容上的硬性要求
|
||||||
- other_rules: 不属于以上分类的其他规则
|
- other_rules: 不属于以上分类的其他规则
|
||||||
- 如果某项没有提取到内容,使用空数组或 null"""
|
- 如果某项没有提取到内容,使用空数组或 null
|
||||||
|
- 重要:JSON 字符串值中不要使用中文引号(""),使用单引号或直接省略"""
|
||||||
|
|
||||||
response = await ai_client.chat_completion(
|
response = await ai_client.chat_completion(
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
@ -767,12 +786,7 @@ async def _ai_parse_platform_rules(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 解析 AI 响应
|
# 解析 AI 响应
|
||||||
content = response.content.strip()
|
content = _extract_json_from_ai_response(response.content)
|
||||||
if content.startswith("```"):
|
|
||||||
content = content.split("\n", 1)[1]
|
|
||||||
if content.endswith("```"):
|
|
||||||
content = content.rsplit("\n", 1)[0]
|
|
||||||
|
|
||||||
parsed = json.loads(content)
|
parsed = json.loads(content)
|
||||||
|
|
||||||
# 校验并补全字段
|
# 校验并补全字段
|
||||||
@ -784,14 +798,142 @@ async def _ai_parse_platform_rules(
|
|||||||
"other_rules": parsed.get("other_rules", []),
|
"other_rules": parsed.get("other_rules", []),
|
||||||
}
|
}
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError as e:
|
||||||
logger.warning("AI 返回内容非 JSON,降级为空规则")
|
logger.warning(f"AI 返回内容非 JSON,降级为空规则: {e}")
|
||||||
return _empty_parsed_rules()
|
return _empty_parsed_rules()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AI 解析平台规则失败: {e}")
|
logger.error(f"AI 解析平台规则失败: {e}")
|
||||||
return _empty_parsed_rules()
|
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:
|
def _empty_parsed_rules() -> dict:
|
||||||
"""返回空的解析规则结构"""
|
"""返回空的解析规则结构"""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
文件上传 API
|
文件上传 API
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -168,3 +168,61 @@ async def get_signed_url(
|
|||||||
signed_url=signed_url,
|
signed_url=signed_url,
|
||||||
expire_seconds=expire,
|
expire_seconds=expire,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|||||||
@ -54,8 +54,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting (仅生产环境启用)
|
||||||
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
|
if _is_production:
|
||||||
|
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
|
||||||
|
|
||||||
# 注册路由
|
# 注册路由
|
||||||
app.include_router(health.router, prefix="/api/v1")
|
app.include_router(health.router, prefix="/api/v1")
|
||||||
|
|||||||
@ -49,10 +49,14 @@ class Brief(Base, TimestampMixin):
|
|||||||
# 其他要求(自由文本)
|
# 其他要求(自由文本)
|
||||||
other_requirements: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
other_requirements: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
# 附件文档(代理商上传的参考资料)
|
# 附件文档(品牌方上传的参考资料)
|
||||||
# [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...]
|
# [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...]
|
||||||
attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
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")
|
project: Mapped["Project"] = relationship("Project", back_populates="brief")
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,9 @@ class Project(Base, TimestampMixin):
|
|||||||
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
deadline: 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(
|
status: Mapped[str] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
|
|||||||
@ -47,7 +47,7 @@ class ReviewTask(Base, TimestampMixin):
|
|||||||
# 视频信息
|
# 视频信息
|
||||||
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||||
platform: Mapped[Platform] = mapped_column(
|
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,
|
nullable=False,
|
||||||
)
|
)
|
||||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
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(
|
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,
|
default=TaskStatus.PENDING,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
|
|||||||
@ -70,7 +70,7 @@ class Task(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 当前阶段
|
# 当前阶段
|
||||||
stage: Mapped[TaskStage] = mapped_column(
|
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,
|
default=TaskStage.SCRIPT_UPLOAD,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
@ -88,7 +88,7 @@ class Task(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 脚本代理商审核
|
# 脚本代理商审核
|
||||||
script_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
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,
|
nullable=True,
|
||||||
)
|
)
|
||||||
script_agency_comment: Mapped[Optional[str]] = mapped_column(Text, 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(
|
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,
|
nullable=True,
|
||||||
)
|
)
|
||||||
script_brand_comment: Mapped[Optional[str]] = mapped_column(Text, 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(
|
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,
|
nullable=True,
|
||||||
)
|
)
|
||||||
video_agency_comment: Mapped[Optional[str]] = mapped_column(Text, 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(
|
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,
|
nullable=True,
|
||||||
)
|
)
|
||||||
video_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
video_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class User(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 角色
|
# 角色
|
||||||
role: Mapped[UserRole] = mapped_column(
|
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,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class BriefCreateRequest(BaseModel):
|
|||||||
max_duration: Optional[int] = None
|
max_duration: Optional[int] = None
|
||||||
other_requirements: Optional[str] = None
|
other_requirements: Optional[str] = None
|
||||||
attachments: Optional[List[dict]] = None
|
attachments: Optional[List[dict]] = None
|
||||||
|
agency_attachments: Optional[List[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
class BriefUpdateRequest(BaseModel):
|
class BriefUpdateRequest(BaseModel):
|
||||||
@ -34,6 +35,12 @@ class BriefUpdateRequest(BaseModel):
|
|||||||
max_duration: Optional[int] = None
|
max_duration: Optional[int] = None
|
||||||
other_requirements: Optional[str] = None
|
other_requirements: Optional[str] = None
|
||||||
attachments: Optional[List[dict]] = None
|
attachments: Optional[List[dict]] = None
|
||||||
|
agency_attachments: Optional[List[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgencyBriefUpdateRequest(BaseModel):
|
||||||
|
"""代理商更新 Brief 请求(仅允许更新 agency_attachments)"""
|
||||||
|
agency_attachments: Optional[List[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
# ===== 响应 =====
|
# ===== 响应 =====
|
||||||
@ -53,6 +60,7 @@ class BriefResponse(BaseModel):
|
|||||||
max_duration: Optional[int] = None
|
max_duration: Optional[int] = None
|
||||||
other_requirements: Optional[str] = None
|
other_requirements: Optional[str] = None
|
||||||
attachments: Optional[List[dict]] = None
|
attachments: Optional[List[dict]] = None
|
||||||
|
agency_attachments: Optional[List[dict]] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ class ProjectCreateRequest(BaseModel):
|
|||||||
"""创建项目请求(品牌方操作)"""
|
"""创建项目请求(品牌方操作)"""
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
start_date: Optional[datetime] = None
|
start_date: Optional[datetime] = None
|
||||||
deadline: Optional[datetime] = None
|
deadline: Optional[datetime] = None
|
||||||
agency_ids: Optional[List[str]] = None # 分配的代理商 ID 列表
|
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)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
start_date: Optional[datetime] = None
|
start_date: Optional[datetime] = None
|
||||||
deadline: Optional[datetime] = None
|
deadline: Optional[datetime] = None
|
||||||
status: Optional[str] = Field(None, pattern="^(active|completed|archived)$")
|
status: Optional[str] = Field(None, pattern="^(active|completed|archived)$")
|
||||||
@ -45,6 +47,7 @@ class ProjectResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
brand_id: str
|
brand_id: str
|
||||||
brand_name: Optional[str] = None
|
brand_name: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@ -48,7 +48,7 @@ class OpenAICompatibleClient:
|
|||||||
base_url: str,
|
base_url: str,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
provider: str = "openai",
|
provider: str = "openai",
|
||||||
timeout: float = 60.0,
|
timeout: float = 180.0,
|
||||||
):
|
):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
|
|||||||
@ -17,6 +17,9 @@ class DocumentParser:
|
|||||||
"""
|
"""
|
||||||
下载文档并解析为纯文本
|
下载文档并解析为纯文本
|
||||||
|
|
||||||
|
优先使用 TOS SDK 直接下载(私有桶无需签名),
|
||||||
|
回退到 HTTP 预签名 URL 下载。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
document_url: 文档 URL (TOS)
|
document_url: 文档 URL (TOS)
|
||||||
document_name: 原始文件名(用于判断格式)
|
document_name: 原始文件名(用于判断格式)
|
||||||
@ -24,16 +27,19 @@ class DocumentParser:
|
|||||||
Returns:
|
Returns:
|
||||||
提取的纯文本
|
提取的纯文本
|
||||||
"""
|
"""
|
||||||
# 下载到临时文件
|
|
||||||
tmp_path: Optional[str] = None
|
tmp_path: Optional[str] = None
|
||||||
try:
|
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 ""
|
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)
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
|
||||||
tmp.write(resp.content)
|
tmp.write(content)
|
||||||
tmp_path = tmp.name
|
tmp_path = tmp.name
|
||||||
|
|
||||||
return DocumentParser.parse_file(tmp_path, document_name)
|
return DocumentParser.parse_file(tmp_path, document_name)
|
||||||
@ -41,6 +47,75 @@ class DocumentParser:
|
|||||||
if tmp_path and os.path.exists(tmp_path):
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def download_and_get_images(document_url: str, document_name: str) -> Optional[list[str]]:
|
||||||
|
"""
|
||||||
|
下载 PDF 并将页面转为 base64 图片列表(用于图片型 PDF 的 AI 视觉解析)。
|
||||||
|
非 PDF 或非图片型 PDF 返回 None。
|
||||||
|
"""
|
||||||
|
ext = document_name.rsplit(".", 1)[-1].lower() if "." in document_name else ""
|
||||||
|
if ext != "pdf":
|
||||||
|
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=".pdf") as tmp:
|
||||||
|
tmp.write(file_content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
if DocumentParser.is_image_pdf(tmp_path):
|
||||||
|
return DocumentParser.pdf_to_images_base64(tmp_path)
|
||||||
|
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 直接下载文件(私有桶安全访问)"""
|
||||||
|
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:
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_key = parse_file_key_from_url(document_url)
|
||||||
|
if not file_key or file_key == document_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
region = settings.TOS_REGION
|
||||||
|
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||||
|
|
||||||
|
client = tos_sdk.TosClientV2(
|
||||||
|
ak=settings.TOS_ACCESS_KEY_ID,
|
||||||
|
sk=settings.TOS_SECRET_ACCESS_KEY,
|
||||||
|
endpoint=f"https://{endpoint}",
|
||||||
|
region=region,
|
||||||
|
)
|
||||||
|
resp = client.get_object(bucket=settings.TOS_BUCKET_NAME, key=file_key)
|
||||||
|
return resp.read()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.get(signed_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_file(file_path: str, file_name: str) -> str:
|
def parse_file(file_path: str, file_name: str) -> str:
|
||||||
"""
|
"""
|
||||||
@ -68,16 +143,73 @@ class DocumentParser:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_pdf(path: str) -> str:
|
def _parse_pdf(path: str) -> str:
|
||||||
"""pdfplumber 提取 PDF 文本"""
|
"""PyMuPDF 提取 PDF 文本,回退 pdfplumber"""
|
||||||
import pdfplumber
|
import fitz
|
||||||
|
|
||||||
texts = []
|
texts = []
|
||||||
with pdfplumber.open(path) as pdf:
|
doc = fitz.open(path)
|
||||||
for page in pdf.pages:
|
for page in doc:
|
||||||
text = page.extract_text()
|
text = page.get_text()
|
||||||
if text:
|
if text and text.strip():
|
||||||
texts.append(text)
|
texts.append(text.strip())
|
||||||
return "\n".join(texts)
|
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
|
@staticmethod
|
||||||
def _parse_docx(path: str) -> str:
|
def _parse_docx(path: str) -> str:
|
||||||
|
|||||||
@ -9,6 +9,8 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-miaosi}
|
POSTGRES_DB: ${POSTGRES_DB:-miaosi}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -21,6 +23,8 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: miaosi-redis
|
container_name: miaosi-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/redis:/data
|
- ./data/redis:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -82,7 +86,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
celery-worker: {}
|
celery-worker:
|
||||||
|
condition: service_started
|
||||||
command: celery -A app.celery_app beat -l info
|
command: celery -A app.celery_app beat -l info
|
||||||
|
|
||||||
# Next.js 前端
|
# Next.js 前端
|
||||||
|
|||||||
@ -165,6 +165,7 @@ async def seed_data() -> None:
|
|||||||
brand_id=BRAND_ID,
|
brand_id=BRAND_ID,
|
||||||
name="2026春季新品推广",
|
name="2026春季新品推广",
|
||||||
description="春季新品防晒霜推广活动,面向 18-35 岁女性用户,重点投放抖音和小红书平台",
|
description="春季新品防晒霜推广活动,面向 18-35 岁女性用户,重点投放抖音和小红书平台",
|
||||||
|
platform="douyin",
|
||||||
start_date=NOW,
|
start_date=NOW,
|
||||||
deadline=NOW + timedelta(days=30),
|
deadline=NOW + timedelta(days=30),
|
||||||
status="active",
|
status="active",
|
||||||
|
|||||||
@ -25,7 +25,7 @@ alembic upgrade head
|
|||||||
|
|
||||||
# 填充种子数据
|
# 填充种子数据
|
||||||
echo "填充种子数据..."
|
echo "填充种子数据..."
|
||||||
python -m scripts.seed
|
python3 -m scripts.seed
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== 基础服务已启动 ==="
|
echo "=== 基础服务已启动 ==="
|
||||||
|
|||||||
@ -28,12 +28,25 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
File,
|
File,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search
|
Search,
|
||||||
|
AlertCircle,
|
||||||
|
RotateCcw
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getPlatformInfo } from '@/lib/platforms'
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
|
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
|
||||||
import type { RuleConflict } from '@/types/rules'
|
import type { RuleConflict } from '@/types/rules'
|
||||||
|
|
||||||
|
// 单个文件上传状态
|
||||||
|
interface UploadingFileItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
size: string
|
||||||
|
status: 'uploading' | 'error'
|
||||||
|
progress: number
|
||||||
|
error?: string
|
||||||
|
file?: File
|
||||||
|
}
|
||||||
import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
|
import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
|
||||||
import type { ProjectResponse } from '@/types/project'
|
import type { ProjectResponse } from '@/types/project'
|
||||||
|
|
||||||
@ -44,6 +57,7 @@ type BriefFile = {
|
|||||||
type: 'brief' | 'rule' | 'reference'
|
type: 'brief' | 'rule' | 'reference'
|
||||||
size: string
|
size: string
|
||||||
uploadedAt: string
|
uploadedAt: string
|
||||||
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 代理商上传的Brief文档(可编辑)
|
// 代理商上传的Brief文档(可编辑)
|
||||||
@ -53,6 +67,7 @@ type AgencyFile = {
|
|||||||
size: string
|
size: string
|
||||||
uploadedAt: string
|
uploadedAt: string
|
||||||
description?: string
|
description?: string
|
||||||
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 视图类型 ====================
|
// ==================== 视图类型 ====================
|
||||||
@ -147,6 +162,15 @@ const platformRules = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 工具函数 ====================
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 组件 ====================
|
// ==================== 组件 ====================
|
||||||
|
|
||||||
function BriefDetailSkeleton() {
|
function BriefDetailSkeleton() {
|
||||||
@ -185,6 +209,10 @@ export default function BriefConfigPage() {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const projectId = params.id as string
|
const projectId = params.id as string
|
||||||
|
const agencyFileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// 上传中的文件跟踪
|
||||||
|
const [uploadingFiles, setUploadingFiles] = useState<UploadingFileItem[]>([])
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -206,7 +234,7 @@ export default function BriefConfigPage() {
|
|||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isAIParsing, setIsAIParsing] = useState(false)
|
const [isAIParsing, setIsAIParsing] = useState(false)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const isUploading = uploadingFiles.some(f => f.status === 'uploading')
|
||||||
|
|
||||||
// 规则冲突检测
|
// 规则冲突检测
|
||||||
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
|
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
|
||||||
@ -310,6 +338,7 @@ export default function BriefConfigPage() {
|
|||||||
type: 'brief' as const,
|
type: 'brief' as const,
|
||||||
size: att.size || '未知',
|
size: att.size || '未知',
|
||||||
uploadedAt: brief!.created_at.split('T')[0],
|
uploadedAt: brief!.created_at.split('T')[0],
|
||||||
|
url: att.url,
|
||||||
})) || []
|
})) || []
|
||||||
|
|
||||||
if (brief?.file_name) {
|
if (brief?.file_name) {
|
||||||
@ -319,6 +348,7 @@ export default function BriefConfigPage() {
|
|||||||
type: 'brief' as const,
|
type: 'brief' as const,
|
||||||
size: '未知',
|
size: '未知',
|
||||||
uploadedAt: brief.created_at.split('T')[0],
|
uploadedAt: brief.created_at.split('T')[0],
|
||||||
|
url: brief.file_url || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,7 +370,13 @@ export default function BriefConfigPage() {
|
|||||||
setAgencyConfig({
|
setAgencyConfig({
|
||||||
status: hasBrief ? 'configured' : 'pending',
|
status: hasBrief ? 'configured' : 'pending',
|
||||||
configuredAt: hasBrief ? (brief!.updated_at.split('T')[0]) : '',
|
configuredAt: hasBrief ? (brief!.updated_at.split('T')[0]) : '',
|
||||||
agencyFiles: [], // 后端暂无代理商文档管理
|
agencyFiles: (brief?.agency_attachments || []).map((att: any) => ({
|
||||||
|
id: att.id || `af-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
name: att.name,
|
||||||
|
size: att.size || '未知',
|
||||||
|
uploadedAt: brief!.updated_at?.split('T')[0] || '',
|
||||||
|
url: att.url,
|
||||||
|
})),
|
||||||
aiParsedContent: {
|
aiParsedContent: {
|
||||||
productName: brief?.brand_tone || '待解析',
|
productName: brief?.brand_tone || '待解析',
|
||||||
targetAudience: '待解析',
|
targetAudience: '待解析',
|
||||||
@ -375,8 +411,17 @@ export default function BriefConfigPage() {
|
|||||||
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
|
const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin
|
||||||
|
|
||||||
// 下载文件
|
// 下载文件
|
||||||
const handleDownload = (file: BriefFile) => {
|
const handleDownload = async (file: BriefFile) => {
|
||||||
toast.info(`下载文件: ${file.name}`)
|
if (USE_MOCK || !file.url) {
|
||||||
|
toast.info(`下载文件: ${file.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const signedUrl = await api.getSignedUrl(file.url)
|
||||||
|
window.open(signedUrl, '_blank')
|
||||||
|
} catch {
|
||||||
|
toast.error('获取下载链接失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预览文件
|
// 预览文件
|
||||||
@ -418,6 +463,12 @@ export default function BriefConfigPage() {
|
|||||||
competitors: brandBrief.brandRules.competitors,
|
competitors: brandBrief.brandRules.competitors,
|
||||||
brand_tone: agencyConfig.aiParsedContent.productName,
|
brand_tone: agencyConfig.aiParsedContent.productName,
|
||||||
other_requirements: brandBrief.brandRules.restrictions,
|
other_requirements: brandBrief.brandRules.restrictions,
|
||||||
|
agency_attachments: agencyConfig.agencyFiles.map(f => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
url: f.url || '',
|
||||||
|
size: f.size,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试更新,如果 Brief 不存在则创建
|
// 尝试更新,如果 Brief 不存在则创建
|
||||||
@ -487,24 +538,81 @@ export default function BriefConfigPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 代理商文档操作
|
// 上传单个代理商文件
|
||||||
const handleUploadAgencyFile = async () => {
|
const uploadSingleAgencyFile = async (file: File, fileId: string) => {
|
||||||
setIsUploading(true)
|
if (USE_MOCK) {
|
||||||
// 模拟上传
|
for (let p = 20; p <= 80; p += 20) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
await new Promise(r => setTimeout(r, 300))
|
||||||
const newFile: AgencyFile = {
|
setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
|
||||||
id: `af${Date.now()}`,
|
}
|
||||||
name: '新上传文档.pdf',
|
await new Promise(r => setTimeout(r, 300))
|
||||||
size: '1.2MB',
|
const newFile: AgencyFile = {
|
||||||
uploadedAt: new Date().toISOString().split('T')[0],
|
id: fileId, name: file.name, size: formatFileSize(file.size),
|
||||||
description: '新上传的文档'
|
uploadedAt: new Date().toISOString().split('T')[0],
|
||||||
|
}
|
||||||
|
setAgencyConfig(prev => ({ ...prev, agencyFiles: [...prev.agencyFiles, newFile] }))
|
||||||
|
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
setAgencyConfig(prev => ({
|
|
||||||
...prev,
|
try {
|
||||||
agencyFiles: [...prev.agencyFiles, newFile]
|
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 newFile: AgencyFile = {
|
||||||
|
id: fileId, name: file.name, size: formatFileSize(file.size),
|
||||||
|
uploadedAt: new Date().toISOString().split('T')[0], url: result.url,
|
||||||
|
}
|
||||||
|
setAgencyConfig(prev => ({ ...prev, agencyFiles: [...prev.agencyFiles, newFile] }))
|
||||||
|
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 retryAgencyFileUpload = (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
|
||||||
|
))
|
||||||
|
uploadSingleAgencyFile(item.file, fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUploadingFile = (id: string) => {
|
||||||
|
setUploadingFiles(prev => prev.filter(f => f.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理商文档操作
|
||||||
|
const handleUploadAgencyFile = (e?: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e) {
|
||||||
|
agencyFileInputRef.current?.click()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
const fileList = Array.from(files)
|
||||||
|
e.target.value = ''
|
||||||
|
const newItems: UploadingFileItem[] = fileList.map(file => ({
|
||||||
|
id: `af-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: file.name,
|
||||||
|
size: formatFileSize(file.size),
|
||||||
|
status: 'uploading' as const,
|
||||||
|
progress: 0,
|
||||||
|
file,
|
||||||
}))
|
}))
|
||||||
setIsUploading(false)
|
setUploadingFiles(prev => [...prev, ...newItems])
|
||||||
toast.success('文档上传成功!')
|
newItems.forEach(item => uploadSingleAgencyFile(item.file!, item.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeAgencyFile = (id: string) => {
|
const removeAgencyFile = (id: string) => {
|
||||||
@ -518,8 +626,17 @@ export default function BriefConfigPage() {
|
|||||||
setPreviewAgencyFile(file)
|
setPreviewAgencyFile(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadAgencyFile = (file: AgencyFile) => {
|
const handleDownloadAgencyFile = async (file: AgencyFile) => {
|
||||||
toast.info(`下载文件: ${file.name}`)
|
if (USE_MOCK || !file.url) {
|
||||||
|
toast.info(`下载文件: ${file.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const signedUrl = await api.getSignedUrl(file.url)
|
||||||
|
window.open(signedUrl, '_blank')
|
||||||
|
} catch {
|
||||||
|
toast.error('获取下载链接失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -721,7 +838,7 @@ export default function BriefConfigPage() {
|
|||||||
<Eye size={14} />
|
<Eye size={14} />
|
||||||
管理文档
|
管理文档
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
|
<Button size="sm" onClick={() => handleUploadAgencyFile()} disabled={isUploading}>
|
||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
{isUploading ? '上传中...' : '上传文档'}
|
{isUploading ? '上传中...' : '上传文档'}
|
||||||
</Button>
|
</Button>
|
||||||
@ -759,11 +876,51 @@ export default function BriefConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{/* 上传中/失败的文件 */}
|
||||||
|
{uploadingFiles.map((file) => (
|
||||||
|
<div key={file.id} className="p-4 rounded-lg border border-accent-indigo/20 bg-accent-indigo/5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
|
||||||
|
{file.status === 'uploading'
|
||||||
|
? <Loader2 size={20} className="animate-spin text-accent-indigo" />
|
||||||
|
: <AlertCircle size={20} className="text-accent-coral" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`font-medium text-sm truncate ${file.status === 'error' ? 'text-accent-coral' : 'text-text-primary'}`}>
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-0.5">
|
||||||
|
{file.status === 'uploading' ? `${file.progress}% · ${file.size}` : file.size}
|
||||||
|
</p>
|
||||||
|
{file.status === 'uploading' && (
|
||||||
|
<div className="mt-2 h-1.5 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 text-xs text-accent-coral">{file.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{file.status === 'error' && (
|
||||||
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => retryAgencyFileUpload(file.id)} className="flex-1">
|
||||||
|
<RotateCcw size={14} /> 重试
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => removeUploadingFile(file.id)} className="text-accent-coral hover:text-accent-coral">
|
||||||
|
<Trash2 size={14} /> 删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* 上传占位卡片 */}
|
{/* 上传占位卡片 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleUploadAgencyFile}
|
onClick={() => handleUploadAgencyFile()}
|
||||||
disabled={isUploading}
|
|
||||||
className="p-4 rounded-lg border-2 border-dashed border-border-subtle hover:border-accent-indigo/50 transition-colors flex flex-col items-center justify-center gap-2 min-h-[140px]"
|
className="p-4 rounded-lg border-2 border-dashed border-border-subtle hover:border-accent-indigo/50 transition-colors flex flex-col items-center justify-center gap-2 min-h-[140px]"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-bg-elevated flex items-center justify-center">
|
||||||
@ -1060,7 +1217,7 @@ export default function BriefConfigPage() {
|
|||||||
<p className="text-sm text-text-secondary">
|
<p className="text-sm text-text-secondary">
|
||||||
以下文档将展示给达人查看,可以添加、删除或预览文档
|
以下文档将展示给达人查看,可以添加、删除或预览文档
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
|
<Button size="sm" onClick={() => handleUploadAgencyFile()} disabled={isUploading}>
|
||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
{isUploading ? '上传中...' : '上传文档'}
|
{isUploading ? '上传中...' : '上传文档'}
|
||||||
</Button>
|
</Button>
|
||||||
@ -1136,6 +1293,15 @@ export default function BriefConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 隐藏的文件上传 input */}
|
||||||
|
<input
|
||||||
|
ref={agencyFileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleUploadAgencyFile}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 规则冲突检测结果弹窗 */}
|
{/* 规则冲突检测结果弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showConflictModal}
|
isOpen={showConflictModal}
|
||||||
|
|||||||
@ -45,6 +45,11 @@ type MessageType =
|
|||||||
| 'task_deadline' // 任务截止提醒
|
| 'task_deadline' // 任务截止提醒
|
||||||
| 'brand_brief_updated' // 品牌方更新了Brief
|
| 'brand_brief_updated' // 品牌方更新了Brief
|
||||||
| 'system_notice' // 系统通知
|
| 'system_notice' // 系统通知
|
||||||
|
| 'new_task' // 新任务
|
||||||
|
| 'pass' // 审核通过
|
||||||
|
| 'reject' // 审核驳回
|
||||||
|
| 'force_pass' // 强制通过
|
||||||
|
| 'approve' // 审核批准
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string
|
id: string
|
||||||
@ -299,19 +304,31 @@ export default function AgencyMessagesPage() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api.getMessages({ page: 1, page_size: 50 })
|
const res = await api.getMessages({ page: 1, page_size: 50 })
|
||||||
const mapped: Message[] = res.items.map(item => ({
|
const typeIconMap: Record<string, { icon: typeof Bell; iconColor: string; bgColor: string }> = {
|
||||||
id: item.id,
|
new_task: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
||||||
type: (item.type || 'system_notice') as MessageType,
|
pass: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||||||
title: item.title,
|
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||||||
content: item.content,
|
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
|
||||||
time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '',
|
force_pass: { icon: CheckCircle, iconColor: 'text-accent-amber', bgColor: 'bg-accent-amber/20' },
|
||||||
read: item.is_read,
|
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
|
||||||
icon: Bell,
|
}
|
||||||
iconColor: 'text-text-secondary',
|
const defaultIcon = { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' }
|
||||||
bgColor: 'bg-bg-elevated',
|
const mapped: Message[] = res.items.map(item => {
|
||||||
taskId: item.related_task_id || undefined,
|
const iconCfg = typeIconMap[item.type] || defaultIcon
|
||||||
projectId: item.related_project_id || undefined,
|
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)
|
setMessages(mapped)
|
||||||
} catch {
|
} catch {
|
||||||
// 加载失败保持 mock 数据
|
// 加载失败保持 mock 数据
|
||||||
|
|||||||
@ -45,6 +45,10 @@ type MessageType =
|
|||||||
| 'brief_config_updated' // 代理商更新了Brief配置
|
| 'brief_config_updated' // 代理商更新了Brief配置
|
||||||
| 'batch_review_done' // 批量审核完成
|
| 'batch_review_done' // 批量审核完成
|
||||||
| 'system_notice' // 系统通知
|
| 'system_notice' // 系统通知
|
||||||
|
| 'new_task' // 新任务分配
|
||||||
|
| 'pass' // 审核通过
|
||||||
|
| 'reject' // 审核驳回
|
||||||
|
| 'approve' // 审核批准
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
id: string
|
id: string
|
||||||
@ -80,6 +84,10 @@ const messageConfig: Record<MessageType, {
|
|||||||
brief_config_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
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' },
|
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' },
|
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">
|
<div className="space-y-3">
|
||||||
{filteredMessages.map((message) => {
|
{filteredMessages.map((message) => {
|
||||||
const config = messageConfig[message.type]
|
const config = messageConfig[message.type] || messageConfig.system_notice
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
const platform = message.platform ? getPlatformInfo(message.platform) : null
|
const platform = message.platform ? getPlatformInfo(message.platform) : null
|
||||||
|
|
||||||
|
|||||||
@ -22,28 +22,29 @@ import { api } from '@/lib/api'
|
|||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useSSE } from '@/contexts/SSEContext'
|
import { useSSE } from '@/contexts/SSEContext'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
import type { ProjectResponse } from '@/types/project'
|
import type { ProjectResponse } from '@/types/project'
|
||||||
|
|
||||||
// ==================== Mock 数据 ====================
|
// ==================== Mock 数据 ====================
|
||||||
const mockProjects: ProjectResponse[] = [
|
const mockProjects: ProjectResponse[] = [
|
||||||
{
|
{
|
||||||
id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌',
|
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',
|
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品牌',
|
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',
|
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品牌',
|
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',
|
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品牌',
|
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',
|
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 }) {
|
function ProjectCard({ project, onEditDeadline }: { project: ProjectResponse; onEditDeadline: (project: ProjectResponse) => void }) {
|
||||||
|
const platformInfo = project.platform ? getPlatformInfo(project.platform) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/brand/projects/${project.id}`}>
|
<Link href={`/brand/projects/${project.id}`}>
|
||||||
<Card className="hover:border-accent-indigo/50 transition-colors cursor-pointer h-full overflow-hidden">
|
<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">
|
<div className={`px-6 py-2 border-b flex items-center justify-between ${
|
||||||
<span className="text-sm font-medium text-accent-indigo">{project.brand_name || '品牌项目'}</span>
|
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} />
|
<StatusTag status={project.status} />
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6 space-y-4">
|
<CardContent className="p-6 space-y-4">
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
AlertCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Bot,
|
Bot,
|
||||||
Users,
|
Users,
|
||||||
@ -21,15 +22,27 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search
|
Search,
|
||||||
|
RotateCcw
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
|
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
|
||||||
import type { RuleConflict } from '@/types/rules'
|
import type { RuleConflict } from '@/types/rules'
|
||||||
import { useOSSUpload } from '@/hooks/useOSSUpload'
|
|
||||||
import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
|
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 数据 ====================
|
// ==================== Mock 数据 ====================
|
||||||
const mockBrief: BriefResponse = {
|
const mockBrief: BriefResponse = {
|
||||||
id: 'bf-001',
|
id: 'bf-001',
|
||||||
@ -84,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 = [
|
const strictnessOptions = [
|
||||||
{ value: 'low', label: '宽松', description: '仅检测明显违规内容' },
|
{ value: 'low', label: '宽松', description: '仅检测明显违规内容' },
|
||||||
@ -114,7 +134,9 @@ export default function ProjectConfigPage() {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const projectId = params.id as string
|
const projectId = params.id as string
|
||||||
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
|
|
||||||
|
// 附件上传跟踪
|
||||||
|
const [uploadingFiles, setUploadingFiles] = useState<UploadFileItem[]>([])
|
||||||
|
|
||||||
// Brief state
|
// Brief state
|
||||||
const [briefExists, setBriefExists] = useState(false)
|
const [briefExists, setBriefExists] = useState(false)
|
||||||
@ -334,32 +356,71 @@ export default function ProjectConfigPage() {
|
|||||||
setCompetitors(competitors.filter(c => c !== name))
|
setCompetitors(competitors.filter(c => c !== name))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachment upload
|
// 上传单个附件(独立跟踪进度)
|
||||||
const handleAttachmentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadSingleAttachment = async (file: File, fileId: string) => {
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
setAttachments([...attachments, {
|
for (let p = 20; p <= 80; p += 20) {
|
||||||
id: `att-${Date.now()}`,
|
await new Promise(r => setTimeout(r, 300))
|
||||||
name: file.name,
|
setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
|
||||||
url: `mock://${file.name}`,
|
}
|
||||||
}])
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await upload(file)
|
const result = await api.proxyUpload(file, 'general', (pct) => {
|
||||||
setAttachments([...attachments, {
|
setUploadingFiles(prev => prev.map(f => f.id === fileId
|
||||||
id: `att-${Date.now()}`,
|
? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) }
|
||||||
name: file.name,
|
: f
|
||||||
url: result.url,
|
))
|
||||||
}])
|
})
|
||||||
} catch {
|
const att: BriefAttachment = { id: fileId, name: file.name, url: result.url, size: formatFileSize(file.size) }
|
||||||
toast.error('文件上传失败')
|
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) => {
|
const removeAttachment = (id: string) => {
|
||||||
setAttachments(attachments.filter(a => a.id !== id))
|
setAttachments(attachments.filter(a => a.id !== id))
|
||||||
}
|
}
|
||||||
@ -629,40 +690,99 @@ export default function ProjectConfigPage() {
|
|||||||
{/* 参考资料 */}
|
{/* 参考资料 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-text-secondary mb-2 block">参考资料</label>
|
<label className="text-sm text-text-secondary mb-2 block">参考资料</label>
|
||||||
<div className="space-y-2">
|
|
||||||
{attachments.map((att) => (
|
<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">
|
||||||
<div key={att.id} className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
|
<Upload size={16} className="text-accent-indigo" />
|
||||||
<FileText size={16} className="text-accent-indigo" />
|
点击上传参考资料(可多选)
|
||||||
<span className="flex-1 text-text-primary">{att.name}</span>
|
<input
|
||||||
{att.size && <span className="text-xs text-text-tertiary">{att.size}</span>}
|
type="file"
|
||||||
<button
|
multiple
|
||||||
type="button"
|
onChange={handleAttachmentUpload}
|
||||||
onClick={() => removeAttachment(att.id)}
|
className="hidden"
|
||||||
className="p-1 rounded hover:bg-bg-page text-text-tertiary hover:text-accent-coral transition-colors"
|
/>
|
||||||
>
|
</label>
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
{/* 文件列表 */}
|
||||||
|
<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>
|
</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">
|
<div className="divide-y divide-border-subtle">
|
||||||
{isUploading ? (
|
{/* 已完成的文件 */}
|
||||||
<>
|
{attachments.map((att) => (
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<div key={att.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
上传中 {uploadProgress}%
|
<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>}
|
||||||
<Upload size={16} />
|
<button
|
||||||
上传参考资料
|
type="button"
|
||||||
</>
|
onClick={() => removeAttachment(att.id)}
|
||||||
)}
|
className="p-1 rounded hover:bg-bg-elevated text-text-tertiary hover:text-accent-coral transition-colors"
|
||||||
<input
|
>
|
||||||
type="file"
|
<Trash2 size={14} />
|
||||||
onChange={handleAttachmentUpload}
|
</button>
|
||||||
className="hidden"
|
</div>
|
||||||
disabled={isUploading}
|
))}
|
||||||
/>
|
|
||||||
</label>
|
{/* 上传中/失败的文件 */}
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -12,17 +12,38 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
FileText,
|
FileText,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
X,
|
AlertCircle,
|
||||||
Users,
|
|
||||||
Search,
|
Search,
|
||||||
Building2,
|
Building2,
|
||||||
Check,
|
Loader2,
|
||||||
Loader2
|
Trash2,
|
||||||
|
RotateCcw
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useOSSUpload } from '@/hooks/useOSSUpload'
|
import { platformOptions } from '@/lib/platforms'
|
||||||
import type { AgencyDetail } from '@/types/organization'
|
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 数据 ====================
|
// ==================== Mock 数据 ====================
|
||||||
const mockAgencies: AgencyDetail[] = [
|
const mockAgencies: AgencyDetail[] = [
|
||||||
@ -37,19 +58,25 @@ const mockAgencies: AgencyDetail[] = [
|
|||||||
export default function CreateProjectPage() {
|
export default function CreateProjectPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
|
|
||||||
|
|
||||||
const [projectName, setProjectName] = useState('')
|
const [projectName, setProjectName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
const [platform, setPlatform] = useState('douyin')
|
||||||
const [deadline, setDeadline] = useState('')
|
const [deadline, setDeadline] = useState('')
|
||||||
const [briefFile, setBriefFile] = useState<File | null>(null)
|
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([])
|
||||||
const [briefFileUrl, setBriefFileUrl] = useState<string | null>(null)
|
|
||||||
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
|
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [agencySearch, setAgencySearch] = useState('')
|
const [agencySearch, setAgencySearch] = useState('')
|
||||||
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
|
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
|
||||||
const [loadingAgencies, setLoadingAgencies] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
const loadAgencies = async () => {
|
const loadAgencies = async () => {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
@ -76,22 +103,85 @@ export default function CreateProjectPage() {
|
|||||||
agency.id.toLowerCase().includes(agencySearch.toLowerCase())
|
agency.id.toLowerCase().includes(agencySearch.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
// 上传单个文件(独立跟踪进度)
|
||||||
const file = e.target.files?.[0]
|
const uploadSingleFile = async (file: File, fileId: string) => {
|
||||||
if (!file) return
|
if (USE_MOCK) {
|
||||||
setBriefFile(file)
|
// Mock:模拟进度
|
||||||
|
for (let p = 20; p <= 80; p += 20) {
|
||||||
if (!USE_MOCK) {
|
await new Promise(r => setTimeout(r, 300))
|
||||||
try {
|
setUploadFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
|
||||||
const result = await upload(file)
|
|
||||||
setBriefFileUrl(result.url)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('文件上传失败')
|
|
||||||
setBriefFile(null)
|
|
||||||
}
|
}
|
||||||
} else {
|
await new Promise(r => setTimeout(r, 300))
|
||||||
setBriefFileUrl('mock://brief-file.pdf')
|
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) => {
|
const toggleAgency = (agencyId: string) => {
|
||||||
@ -116,15 +206,15 @@ export default function CreateProjectPage() {
|
|||||||
const project = await api.createProject({
|
const project = await api.createProject({
|
||||||
name: projectName.trim(),
|
name: projectName.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
|
platform,
|
||||||
deadline,
|
deadline,
|
||||||
agency_ids: selectedAgencies,
|
agency_ids: selectedAgencies,
|
||||||
})
|
})
|
||||||
|
|
||||||
// If brief file was uploaded, create brief
|
// If brief files were uploaded, create brief with attachments
|
||||||
if (briefFileUrl && briefFile) {
|
if (briefFiles.length > 0) {
|
||||||
await api.createBrief(project.id, {
|
await api.createBrief(project.id, {
|
||||||
file_url: briefFileUrl,
|
attachments: briefFiles,
|
||||||
file_name: briefFile.name,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,6 +267,35 @@ export default function CreateProjectPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||||
@ -195,35 +314,125 @@ export default function CreateProjectPage() {
|
|||||||
|
|
||||||
{/* Brief 上传 */}
|
{/* Brief 上传 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">上传 Brief</label>
|
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||||
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
|
上传 Brief 文档
|
||||||
{briefFile ? (
|
</label>
|
||||||
<div className="flex items-center justify-center gap-3">
|
|
||||||
<FileText size={24} className="text-accent-indigo" />
|
{/* 上传区域 */}
|
||||||
<span className="text-text-primary">{briefFile.name}</span>
|
<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">
|
||||||
{isUploading && (
|
<Upload size={28} className="mx-auto text-text-tertiary mb-2" />
|
||||||
<span className="text-xs text-text-tertiary">{uploadProgress}%</span>
|
<p className="text-text-secondary text-sm mb-1">
|
||||||
)}
|
{uploadFiles.length > 0 ? '继续添加文件' : '点击上传 Brief 文件(可多选)'}
|
||||||
<button
|
</p>
|
||||||
type="button"
|
<p className="text-xs text-text-tertiary">支持 PDF、Word、Excel、图片等格式</p>
|
||||||
onClick={() => { setBriefFile(null); setBriefFileUrl(null) }}
|
<input
|
||||||
className="p-1 hover:bg-bg-elevated rounded-full"
|
type="file"
|
||||||
>
|
multiple
|
||||||
<X size={16} className="text-text-tertiary" />
|
onChange={handleFileChange}
|
||||||
</button>
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<label className="cursor-pointer">
|
<div className="divide-y divide-border-subtle">
|
||||||
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
|
{uploadFiles.map((file) => (
|
||||||
<p className="text-text-secondary mb-1">点击或拖拽上传 Brief 文件</p>
|
<div key={file.id} className="px-4 py-3">
|
||||||
<p className="text-xs text-text-tertiary">支持 PDF、Word、Excel 格式</p>
|
<div className="flex items-center gap-3">
|
||||||
<input
|
{/* 状态图标 */}
|
||||||
type="file"
|
{file.status === 'uploading' && (
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx"
|
<Loader2 size={16} className="animate-spin text-accent-indigo flex-shrink-0" />
|
||||||
onChange={handleFileChange}
|
)}
|
||||||
className="hidden"
|
{file.status === 'success' && (
|
||||||
/>
|
<CheckCircle size={16} className="text-accent-green flex-shrink-0" />
|
||||||
</label>
|
)}
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@ -311,7 +520,7 @@ export default function CreateProjectPage() {
|
|||||||
<Button variant="secondary" onClick={() => router.back()}>
|
<Button variant="secondary" onClick={() => router.back()}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || isUploading}>
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || hasUploading}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Modal } from '@/components/ui/Modal'
|
|||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useOSSUpload } from '@/hooks/useOSSUpload'
|
// upload via api.proxyUpload directly
|
||||||
import type {
|
import type {
|
||||||
ForbiddenWordResponse,
|
ForbiddenWordResponse,
|
||||||
CompetitorResponse,
|
CompetitorResponse,
|
||||||
@ -192,7 +192,8 @@ function ListSkeleton({ count = 3 }: { count?: number }) {
|
|||||||
export default function RulesPage() {
|
export default function RulesPage() {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
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 选择
|
// Tab 选择
|
||||||
const [activeTab, setActiveTab] = useState<'platforms' | 'forbidden' | 'competitors' | 'whitelist'>('platforms')
|
const [activeTab, setActiveTab] = useState<'platforms' | 'forbidden' | 'competitors' | 'whitelist'>('platforms')
|
||||||
@ -337,8 +338,14 @@ export default function RulesPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 真实模式: 上传到 TOS
|
// 真实模式: 上传到 TOS (通过后端代理)
|
||||||
const uploadResult = await ossUpload(uploadFile)
|
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
|
documentUrl = uploadResult.url
|
||||||
|
|
||||||
// 调用 AI 解析
|
// 调用 AI 解析
|
||||||
@ -374,6 +381,7 @@ export default function RulesPage() {
|
|||||||
toast.error('文档解析失败:' + (err instanceof Error ? err.message : '未知错误'))
|
toast.error('文档解析失败:' + (err instanceof Error ? err.message : '未知错误'))
|
||||||
} finally {
|
} finally {
|
||||||
setParsing(false)
|
setParsing(false)
|
||||||
|
setIsOssUploading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,9 @@ type MessageType =
|
|||||||
| 'task_deadline' // 任务截止提醒
|
| 'task_deadline' // 任务截止提醒
|
||||||
| 'brief_updated' // Brief更新通知
|
| 'brief_updated' // Brief更新通知
|
||||||
| 'system_notice' // 系统通知
|
| 'system_notice' // 系统通知
|
||||||
|
| 'reject' // 审核驳回
|
||||||
|
| 'force_pass' // 强制通过
|
||||||
|
| 'approve' // 审核批准
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
id: string
|
id: string
|
||||||
@ -87,6 +90,9 @@ const messageConfig: Record<MessageType, {
|
|||||||
task_deadline: { icon: CalendarClock, iconColor: 'text-orange-400', bgColor: 'bg-orange-500/20' },
|
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' },
|
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' },
|
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条消息数据
|
// 12条消息数据
|
||||||
@ -281,7 +287,7 @@ function MessageCard({
|
|||||||
onAcceptInvite?: () => void
|
onAcceptInvite?: () => void
|
||||||
onIgnoreInvite?: () => void
|
onIgnoreInvite?: () => void
|
||||||
}) {
|
}) {
|
||||||
const config = messageConfig[message.type]
|
const config = messageConfig[message.type] || messageConfig.system_notice
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -32,6 +32,7 @@ type AgencyBriefFile = {
|
|||||||
size: string
|
size: string
|
||||||
uploadedAt: string
|
uploadedAt: string
|
||||||
description?: string
|
description?: string
|
||||||
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面视图模型
|
// 页面视图模型
|
||||||
@ -102,13 +103,17 @@ function buildMockViewModel(): BriefViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): 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}`,
|
id: att.id || `att-${idx}`,
|
||||||
name: att.name,
|
name: att.name,
|
||||||
size: att.size || '',
|
size: att.size || '',
|
||||||
uploadedAt: brief.updated_at?.split('T')[0] || '',
|
uploadedAt: brief.updated_at?.split('T')[0] || '',
|
||||||
description: undefined,
|
description: undefined,
|
||||||
|
url: att.url,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Map selling points
|
// Map selling points
|
||||||
@ -233,12 +238,22 @@ export default function TaskBriefPage() {
|
|||||||
loadBriefData()
|
loadBriefData()
|
||||||
}, [loadBriefData])
|
}, [loadBriefData])
|
||||||
|
|
||||||
const handleDownload = (file: AgencyBriefFile) => {
|
const handleDownload = async (file: AgencyBriefFile) => {
|
||||||
toast.info(`下载文件: ${file.name}`)
|
if (USE_MOCK || !file.url) {
|
||||||
|
toast.info(`下载文件: ${file.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const signedUrl = await api.getSignedUrl(file.url)
|
||||||
|
window.open(signedUrl, '_blank')
|
||||||
|
} catch {
|
||||||
|
toast.error('获取下载链接失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadAll = () => {
|
const handleDownloadAll = () => {
|
||||||
toast.info('下载全部文件')
|
if (!viewModel) return
|
||||||
|
viewModel.files.forEach(f => handleDownload(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading || !viewModel) {
|
if (loading || !viewModel) {
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import { Modal } from '@/components/ui/Modal'
|
|||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useSSE } from '@/contexts/SSEContext'
|
import { useSSE } from '@/contexts/SSEContext'
|
||||||
import { useOSSUpload } from '@/hooks/useOSSUpload'
|
|
||||||
import type { TaskResponse, AIReviewResult } from '@/types/task'
|
import type { TaskResponse, AIReviewResult } from '@/types/task'
|
||||||
import type { BriefResponse } from '@/types/brief'
|
import type { BriefResponse } from '@/types/brief'
|
||||||
|
|
||||||
@ -217,64 +216,109 @@ function AgencyBriefSection({ toast, briefData }: { toast: ReturnType<typeof use
|
|||||||
|
|
||||||
function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
|
function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
|
||||||
const [file, setFile] = useState<File | null>(null)
|
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 toast = useToast()
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFile = e.target.files?.[0]
|
const selectedFile = e.target.files?.[0]
|
||||||
if (selectedFile) setFile(selectedFile)
|
if (selectedFile) {
|
||||||
|
setFile(selectedFile)
|
||||||
|
setUploadError(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
setIsUploading(true)
|
||||||
|
setProgress(0)
|
||||||
|
setUploadError(null)
|
||||||
try {
|
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 })
|
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) {
|
} 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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-accent-indigo" />上传脚本</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-accent-indigo" />上传脚本</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 ? (
|
||||||
{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">
|
||||||
<div className="space-y-4">
|
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
|
||||||
<div className="flex items-center justify-center gap-3">
|
<p className="text-text-secondary mb-1">点击上传脚本文件</p>
|
||||||
<FileText size={24} className="text-accent-indigo" />
|
<p className="text-xs text-text-tertiary">支持 Word、PDF、TXT 格式</p>
|
||||||
<span className="text-text-primary">{file.name}</span>
|
<input type="file" accept=".doc,.docx,.pdf,.txt" 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 && (
|
{!isUploading && (
|
||||||
<button type="button" onClick={() => setFile(null)} className="p-1 hover:bg-bg-elevated rounded-full">
|
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
|
||||||
<XCircle size={16} className="text-text-tertiary" />
|
<XCircle size={14} className="text-text-tertiary" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="w-full max-w-xs mx-auto">
|
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
|
||||||
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
|
<div className="h-full bg-accent-indigo rounded-full transition-all duration-300" style={{ width: `${progress}%` }} />
|
||||||
<div className="h-full bg-accent-indigo transition-all" style={{ width: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-text-tertiary">上传中 {progress}%</p>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
</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">支持 Word、PDF、TXT 格式</p>
|
|
||||||
<input type="file" accept=".doc,.docx,.pdf,.txt" onChange={handleFileChange} className="hidden" />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSubmit} disabled={!file || isUploading} fullWidth>
|
<Button onClick={handleSubmit} disabled={!file || isUploading} fullWidth>
|
||||||
{isUploading ? '上传中...' : '提交脚本'}
|
{isUploading ? (
|
||||||
|
<><Loader2 size={16} className="animate-spin" />上传中 {progress}%</>
|
||||||
|
) : '提交脚本'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useSSE } from '@/contexts/SSEContext'
|
import { useSSE } from '@/contexts/SSEContext'
|
||||||
import { useOSSUpload } from '@/hooks/useOSSUpload'
|
|
||||||
import type { TaskResponse } from '@/types/task'
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// ========== 类型 ==========
|
// ========== 类型 ==========
|
||||||
@ -102,64 +101,109 @@ function formatTimestamp(seconds: number): string {
|
|||||||
|
|
||||||
function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
|
function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
|
||||||
const [file, setFile] = useState<File | null>(null)
|
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 toast = useToast()
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFile = e.target.files?.[0]
|
const selectedFile = e.target.files?.[0]
|
||||||
if (selectedFile) setFile(selectedFile)
|
if (selectedFile) {
|
||||||
|
setFile(selectedFile)
|
||||||
|
setUploadError(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
setIsUploading(true)
|
||||||
|
setProgress(0)
|
||||||
|
setUploadError(null)
|
||||||
try {
|
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 })
|
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) {
|
} 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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-purple-400" />上传视频</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-purple-400" />上传视频</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 ? (
|
||||||
{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">
|
||||||
<div className="space-y-4">
|
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
|
||||||
<div className="flex items-center justify-center gap-3">
|
<p className="text-text-secondary mb-1">点击上传视频文件</p>
|
||||||
<Video size={24} className="text-purple-400" />
|
<p className="text-xs text-text-tertiary">支持 MP4、MOV、AVI 格式,最大 500MB</p>
|
||||||
<span className="text-text-primary">{file.name}</span>
|
<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 && (
|
{!isUploading && (
|
||||||
<button type="button" onClick={() => setFile(null)} className="p-1 hover:bg-bg-elevated rounded-full">
|
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
|
||||||
<XCircle size={16} className="text-text-tertiary" />
|
<XCircle size={14} className="text-text-tertiary" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="w-full max-w-xs mx-auto">
|
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
|
||||||
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
|
<div className="h-full bg-purple-400 rounded-full transition-all duration-300" style={{ width: `${progress}%` }} />
|
||||||
<div className="h-full bg-accent-indigo transition-all" style={{ width: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-text-tertiary">上传中 {progress}%</p>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
</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">支持 MP4、MOV、AVI 格式,最大 500MB</p>
|
|
||||||
<input type="file" accept="video/*" onChange={handleFileChange} className="hidden" />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleUpload} disabled={!file || isUploading} fullWidth>
|
<Button onClick={handleUpload} disabled={!file || isUploading} fullWidth>
|
||||||
{isUploading ? '上传中...' : '提交视频'}
|
{isUploading ? (
|
||||||
|
<><Loader2 size={16} className="animate-spin" />上传中 {progress}%</>
|
||||||
|
) : '提交视频'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -21,7 +21,8 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
|||||||
const USER_STORAGE_KEY = 'miaosi_user'
|
const USER_STORAGE_KEY = 'miaosi_user'
|
||||||
|
|
||||||
// 开发模式:使用 mock 数据
|
// 开发模式:使用 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 用户数据
|
// Mock 用户数据
|
||||||
const MOCK_USERS: Record<string, User & { password: string }> = {
|
const MOCK_USERS: Record<string, User & { password: string }> = {
|
||||||
|
|||||||
@ -53,47 +53,12 @@ export function useOSSUpload(fileType: string = 'general'): UseOSSUploadReturn {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 获取上传凭证
|
// 后端代理上传:文件 → 后端 → TOS,避免浏览器 CORS/代理问题
|
||||||
setProgress(10)
|
setProgress(5)
|
||||||
const policy = await api.getUploadPolicy(fileType)
|
const result = await api.proxyUpload(file, fileType, (pct) => {
|
||||||
|
setProgress(5 + Math.round(pct * 0.9))
|
||||||
// 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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. 回调通知后端
|
|
||||||
setProgress(90)
|
|
||||||
const result = await api.fileUploaded(fileKey, file.name, file.size, fileType)
|
|
||||||
|
|
||||||
setProgress(100)
|
setProgress(100)
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -453,6 +453,23 @@ class ApiClient {
|
|||||||
return response.data
|
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
|
* 获取私有桶文件的预签名访问 URL
|
||||||
*/
|
*/
|
||||||
@ -877,7 +894,9 @@ class ApiClient {
|
|||||||
* 上传文档并 AI 解析平台规则
|
* 上传文档并 AI 解析平台规则
|
||||||
*/
|
*/
|
||||||
async parsePlatformRule(data: PlatformRuleParseRequest): Promise<PlatformRuleParseResponse> {
|
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
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export interface BriefResponse {
|
|||||||
max_duration?: number | null
|
max_duration?: number | null
|
||||||
other_requirements?: string | null
|
other_requirements?: string | null
|
||||||
attachments?: BriefAttachment[] | null
|
attachments?: BriefAttachment[] | null
|
||||||
|
agency_attachments?: BriefAttachment[] | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -49,4 +50,5 @@ export interface BriefCreateRequest {
|
|||||||
max_duration?: number
|
max_duration?: number
|
||||||
other_requirements?: string
|
other_requirements?: string
|
||||||
attachments?: BriefAttachment[]
|
attachments?: BriefAttachment[]
|
||||||
|
agency_attachments?: BriefAttachment[]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface ProjectResponse {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
platform?: string | null
|
||||||
brand_id: string
|
brand_id: string
|
||||||
brand_name?: string | null
|
brand_name?: string | null
|
||||||
status: string
|
status: string
|
||||||
@ -34,6 +35,7 @@ export interface ProjectListResponse {
|
|||||||
export interface ProjectCreateRequest {
|
export interface ProjectCreateRequest {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
|
platform?: string
|
||||||
start_date?: string
|
start_date?: string
|
||||||
deadline?: string
|
deadline?: string
|
||||||
agency_ids?: string[]
|
agency_ids?: string[]
|
||||||
@ -42,6 +44,7 @@ export interface ProjectCreateRequest {
|
|||||||
export interface ProjectUpdateRequest {
|
export interface ProjectUpdateRequest {
|
||||||
name?: string
|
name?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
platform?: string
|
||||||
start_date?: string
|
start_date?: string
|
||||||
deadline?: string
|
deadline?: string
|
||||||
status?: 'active' | 'completed' | 'archived'
|
status?: 'active' | 'completed' | 'archived'
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
"x": -271,
|
"x": -271,
|
||||||
"y": -494,
|
"y": -494,
|
||||||
"name": "达人端桌面 - 任务列表",
|
"name": "达人端桌面 - 任务列表",
|
||||||
"enabled": false,
|
|
||||||
"clip": true,
|
"clip": true,
|
||||||
"width": 1440,
|
"width": 1440,
|
||||||
"height": 4300,
|
"height": 4300,
|
||||||
@ -9615,7 +9614,6 @@
|
|||||||
"x": 3080,
|
"x": 3080,
|
||||||
"y": 5772,
|
"y": 5772,
|
||||||
"name": "达人端桌面 - 视频阶段/上传视频",
|
"name": "达人端桌面 - 视频阶段/上传视频",
|
||||||
"enabled": false,
|
|
||||||
"clip": true,
|
"clip": true,
|
||||||
"width": 1440,
|
"width": 1440,
|
||||||
"height": 900,
|
"height": 900,
|
||||||
@ -10447,7 +10445,6 @@
|
|||||||
"x": -1477,
|
"x": -1477,
|
||||||
"y": 4300,
|
"y": 4300,
|
||||||
"name": "达人端桌面 - 消息中心",
|
"name": "达人端桌面 - 消息中心",
|
||||||
"enabled": false,
|
|
||||||
"width": 1440,
|
"width": 1440,
|
||||||
"height": 2400,
|
"height": 2400,
|
||||||
"fill": "$--bg-page",
|
"fill": "$--bg-page",
|
||||||
@ -14314,7 +14311,6 @@
|
|||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 5400,
|
"y": 5400,
|
||||||
"name": "达人端桌面 - 个人中心",
|
"name": "达人端桌面 - 个人中心",
|
||||||
"enabled": false,
|
|
||||||
"clip": true,
|
"clip": true,
|
||||||
"width": 1440,
|
"width": 1440,
|
||||||
"height": 900,
|
"height": 900,
|
||||||
@ -28098,7 +28094,6 @@
|
|||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 13100,
|
"y": 13100,
|
||||||
"name": "代理商端 - 达人管理",
|
"name": "代理商端 - 达人管理",
|
||||||
"enabled": false,
|
|
||||||
"clip": true,
|
"clip": true,
|
||||||
"width": 1440,
|
"width": 1440,
|
||||||
"height": 900,
|
"height": 900,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user