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:
Your Name 2026-02-10 19:00:03 +08:00
parent 58aed5f201
commit 4c9b2f1263
40 changed files with 1479 additions and 360 deletions

6
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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