From 4c9b2f1263d425984d4405fe0c250f1b98b7b041 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 10 Feb 2026 19:00:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Brief=E9=99=84=E4=BB=B6/=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=B9=B3=E5=8F=B0/=E8=A7=84=E5=88=99AI=E8=A7=A3?= =?UTF-8?q?=E6=9E=90/=E6=B6=88=E6=81=AF=E4=B8=AD=E5=BF=83=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20=E9=A1=B9=E7=9B=AE=E5=88=9B=E5=BB=BA=E9=80=9A?= =?UTF-8?q?=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Brief 支持代理商附件上传 (迁移 007) - 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息 - 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题 - 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护 - 项目创建时自动发送消息通知 - .gitignore 排除 backend/data/ 数据库文件 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 + .../alembic/versions/001_initial_tables.py | 6 +- .../versions/002_manual_task_upload_fields.py | 35 +- .../versions/003_user_org_project_task.py | 10 +- .../007_add_brief_agency_attachments.py | 26 ++ .../versions/008_add_project_platform.py | 26 ++ backend/app/api/briefs.py | 63 ++++ backend/app/api/projects.py | 29 +- backend/app/api/rules.py | 182 ++++++++-- backend/app/api/upload.py | 60 +++- backend/app/main.py | 5 +- backend/app/models/brief.py | 6 +- backend/app/models/project.py | 3 + backend/app/models/review.py | 4 +- backend/app/models/task.py | 10 +- backend/app/models/user.py | 2 +- backend/app/schemas/brief.py | 8 + backend/app/schemas/project.py | 3 + backend/app/services/ai_client.py | 2 +- backend/app/services/document_parser.py | 160 ++++++++- backend/docker-compose.yml | 7 +- backend/scripts/seed.py | 1 + backend/scripts/start-dev.sh | 2 +- frontend/app/agency/briefs/[id]/page.tsx | 220 ++++++++++-- frontend/app/agency/messages/page.tsx | 43 ++- frontend/app/brand/messages/page.tsx | 10 +- frontend/app/brand/page.tsx | 27 +- .../app/brand/projects/[id]/config/page.tsx | 228 ++++++++++--- frontend/app/brand/projects/create/page.tsx | 317 +++++++++++++++--- frontend/app/brand/rules/page.tsx | 16 +- frontend/app/creator/messages/page.tsx | 8 +- frontend/app/creator/task/[id]/brief/page.tsx | 25 +- .../app/creator/task/[id]/script/page.tsx | 106 ++++-- frontend/app/creator/task/[id]/video/page.tsx | 106 ++++-- frontend/contexts/AuthContext.tsx | 3 +- frontend/hooks/useOSSUpload.ts | 43 +-- frontend/lib/api.ts | 21 +- frontend/types/brief.ts | 2 + frontend/types/project.ts | 3 + pencil-new.pen | 5 - 40 files changed, 1479 insertions(+), 360 deletions(-) create mode 100644 backend/alembic/versions/007_add_brief_agency_attachments.py create mode 100644 backend/alembic/versions/008_add_project_platform.py diff --git a/.gitignore b/.gitignore index d80167f..baa0c25 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,12 @@ Thumbs.db .env.local .env.*.local +# Database data +backend/data/ + +# Virtual environment +venv/ + # Logs *.log npm-debug.log* diff --git a/backend/alembic/versions/001_initial_tables.py b/backend/alembic/versions/001_initial_tables.py index 61441da..8bedd5a 100644 --- a/backend/alembic/versions/001_initial_tables.py +++ b/backend/alembic/versions/001_initial_tables.py @@ -22,13 +22,15 @@ def upgrade() -> None: # 创建枚举类型 platform_enum = postgresql.ENUM( 'douyin', 'xiaohongshu', 'bilibili', 'kuaishou', - name='platform_enum' + name='platform_enum', + create_type=False, ) platform_enum.create(op.get_bind(), checkfirst=True) task_status_enum = postgresql.ENUM( 'pending', 'processing', 'completed', 'failed', 'approved', 'rejected', - name='task_status_enum' + name='task_status_enum', + create_type=False, ) task_status_enum.create(op.get_bind(), checkfirst=True) diff --git a/backend/alembic/versions/002_manual_task_upload_fields.py b/backend/alembic/versions/002_manual_task_upload_fields.py index 04cabfd..2786179 100644 --- a/backend/alembic/versions/002_manual_task_upload_fields.py +++ b/backend/alembic/versions/002_manual_task_upload_fields.py @@ -17,38 +17,9 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.add_column( - "manual_tasks", - sa.Column("video_uploaded_at", sa.DateTime(timezone=True), nullable=True), - ) - op.alter_column( - "manual_tasks", - "video_url", - existing_type=sa.String(length=2048), - nullable=True, - ) - op.add_column( - "manual_tasks", - sa.Column("script_content", sa.Text(), nullable=True), - ) - op.add_column( - "manual_tasks", - sa.Column("script_file_url", sa.String(length=2048), nullable=True), - ) - op.add_column( - "manual_tasks", - sa.Column("script_uploaded_at", sa.DateTime(timezone=True), nullable=True), - ) + # 原 manual_tasks 表已废弃,字段已合并到 003 的 tasks 表中 + pass def downgrade() -> None: - op.drop_column("manual_tasks", "script_uploaded_at") - op.drop_column("manual_tasks", "script_file_url") - op.drop_column("manual_tasks", "script_content") - op.alter_column( - "manual_tasks", - "video_url", - existing_type=sa.String(length=2048), - nullable=False, - ) - op.drop_column("manual_tasks", "video_uploaded_at") + pass diff --git a/backend/alembic/versions/003_user_org_project_task.py b/backend/alembic/versions/003_user_org_project_task.py index 655bc73..12bb44b 100644 --- a/backend/alembic/versions/003_user_org_project_task.py +++ b/backend/alembic/versions/003_user_org_project_task.py @@ -22,7 +22,8 @@ def upgrade() -> None: # 创建枚举类型 user_role_enum = postgresql.ENUM( 'brand', 'agency', 'creator', - name='user_role_enum' + name='user_role_enum', + create_type=False, ) user_role_enum.create(op.get_bind(), checkfirst=True) @@ -30,10 +31,15 @@ def upgrade() -> None: 'script_upload', 'script_ai_review', 'script_agency_review', 'script_brand_review', 'video_upload', 'video_ai_review', 'video_agency_review', 'video_brand_review', 'completed', 'rejected', - name='task_stage_enum' + name='task_stage_enum', + create_type=False, ) task_stage_enum.create(op.get_bind(), checkfirst=True) + # 扩展 task_status_enum:添加 Task 模型需要的值 + op.execute("ALTER TYPE task_status_enum ADD VALUE IF NOT EXISTS 'passed'") + op.execute("ALTER TYPE task_status_enum ADD VALUE IF NOT EXISTS 'force_passed'") + # 用户表 op.create_table( 'users', diff --git a/backend/alembic/versions/007_add_brief_agency_attachments.py b/backend/alembic/versions/007_add_brief_agency_attachments.py new file mode 100644 index 0000000..b7173f0 --- /dev/null +++ b/backend/alembic/versions/007_add_brief_agency_attachments.py @@ -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') diff --git a/backend/alembic/versions/008_add_project_platform.py b/backend/alembic/versions/008_add_project_platform.py new file mode 100644 index 0000000..0d4feba --- /dev/null +++ b/backend/alembic/versions/008_add_project_platform.py @@ -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') diff --git a/backend/app/api/briefs.py b/backend/app/api/briefs.py index 583dad3..42d6703 100644 --- a/backend/app/api/briefs.py +++ b/backend/app/api/briefs.py @@ -16,6 +16,7 @@ from app.api.deps import get_current_user from app.schemas.brief import ( BriefCreateRequest, BriefUpdateRequest, + AgencyBriefUpdateRequest, BriefResponse, ) from app.services.auth import generate_id @@ -81,6 +82,7 @@ def _brief_to_response(brief: Brief) -> BriefResponse: max_duration=brief.max_duration, other_requirements=brief.other_requirements, attachments=brief.attachments, + agency_attachments=brief.agency_attachments, created_at=brief.created_at, updated_at=brief.updated_at, ) @@ -137,6 +139,7 @@ async def create_brief( max_duration=request.max_duration, other_requirements=request.other_requirements, attachments=request.attachments, + agency_attachments=request.agency_attachments, ) db.add(brief) await db.flush() @@ -180,3 +183,63 @@ async def update_brief( await db.refresh(brief) return _brief_to_response(brief) + + +@router.patch("/agency-attachments", response_model=BriefResponse) +async def update_brief_agency_attachments( + project_id: str, + request: AgencyBriefUpdateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """更新 Brief 代理商附件(代理商操作) + + 代理商只能更新 agency_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) diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 551fea8..858c3bf 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -23,6 +23,7 @@ from app.schemas.project import ( AgencySummary, ) from app.services.auth import generate_id +from app.services.message_service import create_message router = APIRouter(prefix="/projects", tags=["项目"]) @@ -46,6 +47,7 @@ async def _project_to_response(project: Project, db: AsyncSession) -> ProjectRes id=project.id, name=project.name, description=project.description, + platform=project.platform, brand_id=project.brand_id, brand_name=project.brand.name if project.brand else None, status=project.status, @@ -72,6 +74,7 @@ async def create_project( brand_id=brand.id, name=request.name, description=request.description, + platform=request.platform, start_date=request.start_date, deadline=request.deadline, status="active", @@ -79,7 +82,7 @@ async def create_project( db.add(project) await db.flush() - # 分配代理商 + # 分配代理商(直接 INSERT 关联表,避免 async 懒加载问题) if request.agency_ids: for agency_id in request.agency_ids: result = await db.execute( @@ -87,7 +90,12 @@ async def create_project( ) agency = result.scalar_one_or_none() if agency: - project.agencies.append(agency) + await db.execute( + project_agency_association.insert().values( + project_id=project.id, + agency_id=agency.id, + ) + ) await db.flush() await db.refresh(project) @@ -100,6 +108,21 @@ async def create_project( ) project = result.scalar_one() + # 给品牌方用户发送项目创建成功消息 + brand_user_result = await db.execute( + select(User).where(User.id == brand.user_id) + ) + brand_user = brand_user_result.scalar_one_or_none() + if brand_user: + await create_message( + db=db, + user_id=brand_user.id, + type="system_notice", + title="项目创建成功", + content=f"您的项目「{project.name}」已创建成功", + related_project_id=project.id, + ) + return await _project_to_response(project, db) @@ -248,6 +271,8 @@ async def update_project( project.name = request.name if request.description is not None: project.description = request.description + if request.platform is not None: + project.platform = request.platform if request.start_date is not None: project.start_date = request.start_date if request.deadline is not None: diff --git a/backend/app/api/rules.py b/backend/app/api/rules.py index 16f885c..c1bea53 100644 --- a/backend/app/api/rules.py +++ b/backend/app/api/rules.py @@ -558,22 +558,40 @@ async def parse_platform_rule_document( """ await _ensure_tenant_exists(x_tenant_id, db) - # 1. 下载并解析文档 + # 1. 尝试提取文本;对图片型 PDF 走视觉解析 + document_text = "" + image_b64_list: list[str] = [] + try: - document_text = await DocumentParser.download_and_parse( + # 先检查是否为图片型 PDF + image_b64_list = await DocumentParser.download_and_get_images( request.document_url, request.document_name, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + ) or [] except Exception as e: - logger.error(f"文档解析失败: {e}") - raise HTTPException(status_code=400, detail=f"文档下载或解析失败: {e}") + logger.warning(f"图片 PDF 检测失败,回退文本模式: {e}") - if not document_text.strip(): - raise HTTPException(status_code=400, detail="文档内容为空,无法解析") + if not image_b64_list: + # 非图片 PDF 或检测失败,走文本提取 + try: + document_text = await DocumentParser.download_and_parse( + request.document_url, request.document_name, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"文档解析失败: {e}") + raise HTTPException(status_code=400, detail=f"文档下载或解析失败: {e}") - # 2. AI 解析 - parsed_rules = await _ai_parse_platform_rules(x_tenant_id, request.platform, document_text, db) + if not document_text.strip(): + raise HTTPException(status_code=400, detail="文档内容为空,无法解析") + + # 2. AI 解析(图片模式 or 文本模式) + if image_b64_list: + parsed_rules = await _ai_parse_platform_rules_vision( + x_tenant_id, request.platform, image_b64_list, db, + ) + else: + parsed_rules = await _ai_parse_platform_rules(x_tenant_id, request.platform, document_text, db) # 3. 存入 DB (draft) rule_id = f"pr-{uuid.uuid4().hex[:8]}" @@ -757,7 +775,8 @@ async def _ai_parse_platform_rules( - duration: 视频时长要求,如果文档未提及则为 null - content_requirements: 内容上的硬性要求 - other_rules: 不属于以上分类的其他规则 -- 如果某项没有提取到内容,使用空数组或 null""" +- 如果某项没有提取到内容,使用空数组或 null +- 重要:JSON 字符串值中不要使用中文引号(""),使用单引号或直接省略""" response = await ai_client.chat_completion( messages=[{"role": "user", "content": prompt}], @@ -767,12 +786,7 @@ async def _ai_parse_platform_rules( ) # 解析 AI 响应 - content = response.content.strip() - if content.startswith("```"): - content = content.split("\n", 1)[1] - if content.endswith("```"): - content = content.rsplit("\n", 1)[0] - + content = _extract_json_from_ai_response(response.content) parsed = json.loads(content) # 校验并补全字段 @@ -784,14 +798,142 @@ async def _ai_parse_platform_rules( "other_rules": parsed.get("other_rules", []), } - except json.JSONDecodeError: - logger.warning("AI 返回内容非 JSON,降级为空规则") + except json.JSONDecodeError as e: + logger.warning(f"AI 返回内容非 JSON,降级为空规则: {e}") return _empty_parsed_rules() except Exception as e: logger.error(f"AI 解析平台规则失败: {e}") return _empty_parsed_rules() +async def _ai_parse_platform_rules_vision( + tenant_id: str, + platform: str, + image_b64_list: list[str], + db: AsyncSession, +) -> dict: + """ + 使用 AI 视觉模型从 PDF 页面图片中提取结构化平台规则。 + 用于扫描件/截图型 PDF。 + """ + try: + ai_client = await AIServiceFactory.get_client(tenant_id, db) + if not ai_client: + logger.warning(f"租户 {tenant_id} 未配置 AI 服务,返回空规则") + return _empty_parsed_rules() + + config = await AIServiceFactory.get_config(tenant_id, db) + if not config: + return _empty_parsed_rules() + + vision_model = config.models.get("vision", config.models.get("text", "gpt-4o")) + + # 构建多模态消息 + content: list[dict] = [ + { + "type": "text", + "text": f"""你是平台广告合规规则分析专家。以下是 {platform} 平台规则文档的页面截图。 +请仔细阅读所有页面,从中提取结构化规则。 + +请以 JSON 格式返回,不要包含其他内容: +{{ + "forbidden_words": ["违禁词1", "违禁词2"], + "restricted_words": [{{"word": "xx", "condition": "使用条件", "suggestion": "替换建议"}}], + "duration": {{"min_seconds": 7, "max_seconds": null}}, + "content_requirements": ["必须展示产品正面", "需要口播品牌名"], + "other_rules": [{{"rule": "规则名称", "description": "详细说明"}}] +}} + +注意: +- forbidden_words: 明确禁止使用的词语 +- restricted_words: 有条件限制的词语 +- duration: 视频时长要求,如果文档未提及则为 null +- content_requirements: 内容上的硬性要求 +- other_rules: 不属于以上分类的其他规则 +- 如果某项没有提取到内容,使用空数组或 null +- 重要:JSON 字符串值中不要使用中文引号(\u201c\u201d),使用单引号或直接省略""", + } + ] + for b64 in image_b64_list: + content.append({ + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{b64}"}, + }) + + response = await ai_client.chat_completion( + messages=[{"role": "user", "content": content}], + model=vision_model, + temperature=0.2, + max_tokens=3000, + ) + + # 解析 AI 响应 + resp_content = _extract_json_from_ai_response(response.content) + parsed = json.loads(resp_content) + return { + "forbidden_words": parsed.get("forbidden_words", []), + "restricted_words": parsed.get("restricted_words", []), + "duration": parsed.get("duration"), + "content_requirements": parsed.get("content_requirements", []), + "other_rules": parsed.get("other_rules", []), + } + + except json.JSONDecodeError as e: + logger.warning(f"AI 视觉解析返回内容非 JSON,降级为空规则: {e}") + return _empty_parsed_rules() + except Exception as e: + logger.error(f"AI 视觉解析平台规则失败: {e}") + return _empty_parsed_rules() + + +def _extract_json_from_ai_response(raw: str) -> str: + """ + 从 AI 响应中提取并清理 JSON 文本。 + 处理:markdown 代码块包裹、中文引号等。 + """ + import re + text = raw.strip() + # 去掉 markdown ```json ... ``` 包裹 + m = re.search(r'```(?:json)?\s*\n(.*?)```', text, re.DOTALL) + if m: + text = m.group(1).strip() + return _sanitize_json_string(text) + + +def _sanitize_json_string(text: str) -> str: + """ + 清理 AI 返回的 JSON 文本中的中文引号等特殊字符。 + 中文引号 "" 在 JSON 字符串值内会破坏解析。 + """ + import re + result = [] + in_string = False + i = 0 + while i < len(text): + ch = text[i] + if ch == '\\' and in_string and i + 1 < len(text): + result.append(ch) + result.append(text[i + 1]) + i += 2 + continue + if ch == '"' and not in_string: + in_string = True + result.append(ch) + elif ch == '"' and in_string: + in_string = False + result.append(ch) + elif in_string and ch in '\u201c\u201d\u300c\u300d': + # 中文引号 "" 和「」 → 单引号 + result.append("'") + elif not in_string and ch in '\u201c\u201d': + # JSON 结构层的中文引号 → 英文双引号 + result.append('"') + else: + result.append(ch) + i += 1 + return ''.join(result) + + def _empty_parsed_rules() -> dict: """返回空的解析规则结构""" return { diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 6017081..7ab1d46 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -1,7 +1,7 @@ """ 文件上传 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 typing import Optional from datetime import datetime @@ -168,3 +168,61 @@ async def get_signed_url( signed_url=signed_url, 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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 6aa590b..3b2b20d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -54,8 +54,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): app.add_middleware(SecurityHeadersMiddleware) -# Rate limiting -app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60) +# Rate limiting (仅生产环境启用) +if _is_production: + app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60) # 注册路由 app.include_router(health.router, prefix="/api/v1") diff --git a/backend/app/models/brief.py b/backend/app/models/brief.py index 3f11ffd..abd4116 100644 --- a/backend/app/models/brief.py +++ b/backend/app/models/brief.py @@ -49,10 +49,14 @@ class Brief(Base, TimestampMixin): # 其他要求(自由文本) other_requirements: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - # 附件文档(代理商上传的参考资料) + # 附件文档(品牌方上传的参考资料) # [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...] attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True) + # 代理商附件(代理商上传的补充资料,与品牌方 attachments 分开存储) + # [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...] + agency_attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True) + # 关联 project: Mapped["Project"] = relationship("Project", back_populates="brief") diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 0e8ff83..d8198ed 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -45,6 +45,9 @@ class Project(Base, TimestampMixin): start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) deadline: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + # 发布平台 (douyin/xiaohongshu/bilibili/kuaishou 等) + platform: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None) + # 状态 status: Mapped[str] = mapped_column( String(20), diff --git a/backend/app/models/review.py b/backend/app/models/review.py index ff033b7..dfe5414 100644 --- a/backend/app/models/review.py +++ b/backend/app/models/review.py @@ -47,7 +47,7 @@ class ReviewTask(Base, TimestampMixin): # 视频信息 video_url: Mapped[str] = mapped_column(String(2048), nullable=False) platform: Mapped[Platform] = mapped_column( - SQLEnum(Platform, name="platform_enum"), + SQLEnum(Platform, name="platform_enum", values_callable=lambda x: [e.value for e in x]), nullable=False, ) brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) @@ -55,7 +55,7 @@ class ReviewTask(Base, TimestampMixin): # 审核状态 status: Mapped[TaskStatus] = mapped_column( - SQLEnum(TaskStatus, name="task_status_enum"), + SQLEnum(TaskStatus, name="task_status_enum", values_callable=lambda x: [e.value for e in x]), default=TaskStatus.PENDING, nullable=False, index=True, diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 4473466..45c96eb 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -70,7 +70,7 @@ class Task(Base, TimestampMixin): # 当前阶段 stage: Mapped[TaskStage] = mapped_column( - SQLEnum(TaskStage, name="task_stage_enum"), + SQLEnum(TaskStage, name="task_stage_enum", values_callable=lambda x: [e.value for e in x]), default=TaskStage.SCRIPT_UPLOAD, nullable=False, index=True, @@ -88,7 +88,7 @@ class Task(Base, TimestampMixin): # 脚本代理商审核 script_agency_status: Mapped[Optional[TaskStatus]] = mapped_column( - SQLEnum(TaskStatus, name="task_status_enum"), + SQLEnum(TaskStatus, name="task_status_enum", values_callable=lambda x: [e.value for e in x]), nullable=True, ) script_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True) @@ -97,7 +97,7 @@ class Task(Base, TimestampMixin): # 脚本品牌方终审 script_brand_status: Mapped[Optional[TaskStatus]] = mapped_column( - SQLEnum(TaskStatus, name="task_status_enum", create_type=False), + SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]), nullable=True, ) script_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True) @@ -118,7 +118,7 @@ class Task(Base, TimestampMixin): # 视频代理商审核 video_agency_status: Mapped[Optional[TaskStatus]] = mapped_column( - SQLEnum(TaskStatus, name="task_status_enum", create_type=False), + SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]), nullable=True, ) video_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True) @@ -127,7 +127,7 @@ class Task(Base, TimestampMixin): # 视频品牌方终审 video_brand_status: Mapped[Optional[TaskStatus]] = mapped_column( - SQLEnum(TaskStatus, name="task_status_enum", create_type=False), + SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]), nullable=True, ) video_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index c2e39d7..5bb06c0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -37,7 +37,7 @@ class User(Base, TimestampMixin): # 角色 role: Mapped[UserRole] = mapped_column( - SQLEnum(UserRole, name="user_role_enum"), + SQLEnum(UserRole, name="user_role_enum", values_callable=lambda x: [e.value for e in x]), nullable=False, index=True, ) diff --git a/backend/app/schemas/brief.py b/backend/app/schemas/brief.py index 3766e0a..83cf708 100644 --- a/backend/app/schemas/brief.py +++ b/backend/app/schemas/brief.py @@ -20,6 +20,7 @@ class BriefCreateRequest(BaseModel): max_duration: Optional[int] = None other_requirements: Optional[str] = None attachments: Optional[List[dict]] = None + agency_attachments: Optional[List[dict]] = None class BriefUpdateRequest(BaseModel): @@ -34,6 +35,12 @@ class BriefUpdateRequest(BaseModel): max_duration: Optional[int] = None other_requirements: Optional[str] = None attachments: Optional[List[dict]] = None + agency_attachments: Optional[List[dict]] = None + + +class AgencyBriefUpdateRequest(BaseModel): + """代理商更新 Brief 请求(仅允许更新 agency_attachments)""" + agency_attachments: Optional[List[dict]] = None # ===== 响应 ===== @@ -53,6 +60,7 @@ class BriefResponse(BaseModel): max_duration: Optional[int] = None other_requirements: Optional[str] = None attachments: Optional[List[dict]] = None + agency_attachments: Optional[List[dict]] = None created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index b98f67e..8125a1f 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -12,6 +12,7 @@ class ProjectCreateRequest(BaseModel): """创建项目请求(品牌方操作)""" name: str = Field(..., min_length=1, max_length=255) description: Optional[str] = None + platform: Optional[str] = None start_date: Optional[datetime] = None deadline: Optional[datetime] = None agency_ids: Optional[List[str]] = None # 分配的代理商 ID 列表 @@ -21,6 +22,7 @@ class ProjectUpdateRequest(BaseModel): """更新项目请求""" name: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = None + platform: Optional[str] = None start_date: Optional[datetime] = None deadline: Optional[datetime] = None status: Optional[str] = Field(None, pattern="^(active|completed|archived)$") @@ -45,6 +47,7 @@ class ProjectResponse(BaseModel): id: str name: str description: Optional[str] = None + platform: Optional[str] = None brand_id: str brand_name: Optional[str] = None status: str diff --git a/backend/app/services/ai_client.py b/backend/app/services/ai_client.py index 3532b4e..7683ee4 100644 --- a/backend/app/services/ai_client.py +++ b/backend/app/services/ai_client.py @@ -48,7 +48,7 @@ class OpenAICompatibleClient: base_url: str, api_key: str, provider: str = "openai", - timeout: float = 60.0, + timeout: float = 180.0, ): self.base_url = base_url.rstrip("/") self.api_key = api_key diff --git a/backend/app/services/document_parser.py b/backend/app/services/document_parser.py index 7444f06..334dd4f 100644 --- a/backend/app/services/document_parser.py +++ b/backend/app/services/document_parser.py @@ -17,6 +17,9 @@ class DocumentParser: """ 下载文档并解析为纯文本 + 优先使用 TOS SDK 直接下载(私有桶无需签名), + 回退到 HTTP 预签名 URL 下载。 + Args: document_url: 文档 URL (TOS) document_name: 原始文件名(用于判断格式) @@ -24,16 +27,19 @@ class DocumentParser: Returns: 提取的纯文本 """ - # 下载到临时文件 tmp_path: Optional[str] = None try: - async with httpx.AsyncClient(timeout=60.0) as client: - resp = await client.get(document_url) - resp.raise_for_status() - ext = document_name.rsplit(".", 1)[-1].lower() if "." in document_name else "" + + # 优先用 TOS SDK 直接下载(后端有 AK/SK,无需签名 URL) + content = await DocumentParser._download_via_tos_sdk(document_url) + + if content is None: + # 回退:生成预签名 URL 后用 HTTP 下载 + content = await DocumentParser._download_via_signed_url(document_url) + with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp: - tmp.write(resp.content) + tmp.write(content) tmp_path = tmp.name return DocumentParser.parse_file(tmp_path, document_name) @@ -41,6 +47,75 @@ class DocumentParser: if tmp_path and os.path.exists(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 def parse_file(file_path: str, file_name: str) -> str: """ @@ -68,16 +143,73 @@ class DocumentParser: @staticmethod def _parse_pdf(path: str) -> str: - """pdfplumber 提取 PDF 文本""" - import pdfplumber + """PyMuPDF 提取 PDF 文本,回退 pdfplumber""" + import fitz texts = [] - with pdfplumber.open(path) as pdf: - for page in pdf.pages: - text = page.extract_text() - if text: - texts.append(text) - return "\n".join(texts) + doc = fitz.open(path) + for page in doc: + text = page.get_text() + if text and text.strip(): + texts.append(text.strip()) + doc.close() + + result = "\n".join(texts) + + # 如果 PyMuPDF 提取文本太少,回退 pdfplumber + if len(result.strip()) < 100: + try: + import pdfplumber + texts2 = [] + with pdfplumber.open(path) as pdf: + for page in pdf.pages: + text = page.extract_text() + if text: + texts2.append(text) + fallback = "\n".join(texts2) + if len(fallback.strip()) > len(result.strip()): + result = fallback + except Exception: + pass + + return result + + @staticmethod + def pdf_to_images_base64(path: str, max_pages: int = 5, dpi: int = 150) -> list[str]: + """ + 将 PDF 页面渲染为图片并返回 base64 编码列表。 + 用于处理扫描件/图片型 PDF。 + """ + import fitz + import base64 + + images = [] + doc = fitz.open(path) + for i, page in enumerate(doc): + if i >= max_pages: + break + zoom = dpi / 72 + mat = fitz.Matrix(zoom, zoom) + pix = page.get_pixmap(matrix=mat) + img_bytes = pix.tobytes("png") + b64 = base64.b64encode(img_bytes).decode() + images.append(b64) + doc.close() + return images + + @staticmethod + def is_image_pdf(path: str) -> bool: + """判断 PDF 是否为扫描件/图片型(文本内容极少)""" + import fitz + + doc = fitz.open(path) + total_text = "" + for page in doc: + total_text += page.get_text() + doc.close() + # 去掉页码等噪音后,有效文字少于 200 字符视为图片 PDF + cleaned = "".join(c for c in total_text if c.strip()) + return len(cleaned) < 200 @staticmethod def _parse_docx(path: str) -> str: diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 3d6aeef..a2973fa 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -9,6 +9,8 @@ services: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: ${POSTGRES_DB:-miaosi} + ports: + - "5432:5432" volumes: - ./data/postgres:/var/lib/postgresql/data healthcheck: @@ -21,6 +23,8 @@ services: redis: image: redis:7-alpine container_name: miaosi-redis + ports: + - "6379:6379" volumes: - ./data/redis:/data healthcheck: @@ -82,7 +86,8 @@ services: depends_on: redis: condition: service_healthy - celery-worker: {} + celery-worker: + condition: service_started command: celery -A app.celery_app beat -l info # Next.js 前端 diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py index 3b76978..2042414 100644 --- a/backend/scripts/seed.py +++ b/backend/scripts/seed.py @@ -165,6 +165,7 @@ async def seed_data() -> None: brand_id=BRAND_ID, name="2026春季新品推广", description="春季新品防晒霜推广活动,面向 18-35 岁女性用户,重点投放抖音和小红书平台", + platform="douyin", start_date=NOW, deadline=NOW + timedelta(days=30), status="active", diff --git a/backend/scripts/start-dev.sh b/backend/scripts/start-dev.sh index 353f400..2ff18a4 100755 --- a/backend/scripts/start-dev.sh +++ b/backend/scripts/start-dev.sh @@ -25,7 +25,7 @@ alembic upgrade head # 填充种子数据 echo "填充种子数据..." -python -m scripts.seed +python3 -m scripts.seed echo "" echo "=== 基础服务已启动 ===" diff --git a/frontend/app/agency/briefs/[id]/page.tsx b/frontend/app/agency/briefs/[id]/page.tsx index 940b708..e0383db 100644 --- a/frontend/app/agency/briefs/[id]/page.tsx +++ b/frontend/app/agency/briefs/[id]/page.tsx @@ -28,12 +28,25 @@ import { Trash2, File, Loader2, - Search + Search, + AlertCircle, + RotateCcw } from 'lucide-react' import { getPlatformInfo } from '@/lib/platforms' import { api } from '@/lib/api' import { USE_MOCK, useAuth } from '@/contexts/AuthContext' import type { RuleConflict } from '@/types/rules' + +// 单个文件上传状态 +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 { ProjectResponse } from '@/types/project' @@ -44,6 +57,7 @@ type BriefFile = { type: 'brief' | 'rule' | 'reference' size: string uploadedAt: string + url?: string } // 代理商上传的Brief文档(可编辑) @@ -53,6 +67,7 @@ type AgencyFile = { size: string uploadedAt: 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() { @@ -185,6 +209,10 @@ export default function BriefConfigPage() { const toast = useToast() const { user } = useAuth() const projectId = params.id as string + const agencyFileInputRef = useRef(null) + + // 上传中的文件跟踪 + const [uploadingFiles, setUploadingFiles] = useState([]) // 加载状态 const [loading, setLoading] = useState(true) @@ -206,7 +234,7 @@ export default function BriefConfigPage() { const [isExporting, setIsExporting] = useState(false) const [isSaving, setIsSaving] = 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) @@ -310,6 +338,7 @@ export default function BriefConfigPage() { type: 'brief' as const, size: att.size || '未知', uploadedAt: brief!.created_at.split('T')[0], + url: att.url, })) || [] if (brief?.file_name) { @@ -319,6 +348,7 @@ export default function BriefConfigPage() { type: 'brief' as const, size: '未知', uploadedAt: brief.created_at.split('T')[0], + url: brief.file_url || undefined, }) } @@ -340,7 +370,13 @@ export default function BriefConfigPage() { setAgencyConfig({ status: hasBrief ? 'configured' : 'pending', 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: { productName: brief?.brand_tone || '待解析', targetAudience: '待解析', @@ -375,8 +411,17 @@ export default function BriefConfigPage() { const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin // 下载文件 - const handleDownload = (file: BriefFile) => { - toast.info(`下载文件: ${file.name}`) + const handleDownload = async (file: BriefFile) => { + 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, brand_tone: agencyConfig.aiParsedContent.productName, other_requirements: brandBrief.brandRules.restrictions, + agency_attachments: agencyConfig.agencyFiles.map(f => ({ + id: f.id, + name: f.name, + url: f.url || '', + size: f.size, + })), } // 尝试更新,如果 Brief 不存在则创建 @@ -487,24 +538,81 @@ export default function BriefConfigPage() { })) } - // 代理商文档操作 - const handleUploadAgencyFile = async () => { - setIsUploading(true) - // 模拟上传 - await new Promise(resolve => setTimeout(resolve, 1500)) - const newFile: AgencyFile = { - id: `af${Date.now()}`, - name: '新上传文档.pdf', - size: '1.2MB', - uploadedAt: new Date().toISOString().split('T')[0], - description: '新上传的文档' + // 上传单个代理商文件 + const uploadSingleAgencyFile = async (file: File, fileId: string) => { + if (USE_MOCK) { + for (let p = 20; p <= 80; p += 20) { + await new Promise(r => setTimeout(r, 300)) + setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f)) + } + await new Promise(r => setTimeout(r, 300)) + const newFile: AgencyFile = { + id: fileId, name: file.name, size: formatFileSize(file.size), + 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, - agencyFiles: [...prev.agencyFiles, newFile] + + try { + 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) => { + 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) - toast.success('文档上传成功!') + setUploadingFiles(prev => [...prev, ...newItems]) + newItems.forEach(item => uploadSingleAgencyFile(item.file!, item.id)) } const removeAgencyFile = (id: string) => { @@ -518,8 +626,17 @@ export default function BriefConfigPage() { setPreviewAgencyFile(file) } - const handleDownloadAgencyFile = (file: AgencyFile) => { - toast.info(`下载文件: ${file.name}`) + const handleDownloadAgencyFile = async (file: AgencyFile) => { + 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) { @@ -721,7 +838,7 @@ export default function BriefConfigPage() { 管理文档 - @@ -759,11 +876,51 @@ export default function BriefConfigPage() { ))} + {/* 上传中/失败的文件 */} + {uploadingFiles.map((file) => ( +
+
+
+ {file.status === 'uploading' + ? + : + } +
+
+

+ {file.name} +

+

+ {file.status === 'uploading' ? `${file.progress}% · ${file.size}` : file.size} +

+ {file.status === 'uploading' && ( +
+
+
+ )} + {file.status === 'error' && file.error && ( +

{file.error}

+ )} +
+
+ {file.status === 'error' && ( +
+ + +
+ )} +
+ ))} + {/* 上传占位卡片 */} @@ -1136,6 +1293,15 @@ export default function BriefConfigPage() {
+ {/* 隐藏的文件上传 input */} + + {/* 规则冲突检测结果弹窗 */} ({ - id: item.id, - type: (item.type || 'system_notice') as MessageType, - title: item.title, - content: item.content, - time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '', - read: item.is_read, - icon: Bell, - iconColor: 'text-text-secondary', - bgColor: 'bg-bg-elevated', - taskId: item.related_task_id || undefined, - projectId: item.related_project_id || undefined, - })) + const typeIconMap: Record = { + new_task: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' }, + pass: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' }, + approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' }, + reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' }, + force_pass: { icon: CheckCircle, iconColor: 'text-accent-amber', bgColor: 'bg-accent-amber/20' }, + system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' }, + } + const defaultIcon = { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' } + const mapped: Message[] = res.items.map(item => { + const iconCfg = typeIconMap[item.type] || defaultIcon + return { + id: item.id, + type: (item.type || 'system_notice') as MessageType, + title: item.title, + content: item.content, + time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '', + read: item.is_read, + icon: iconCfg.icon, + iconColor: iconCfg.iconColor, + bgColor: iconCfg.bgColor, + taskId: item.related_task_id || undefined, + projectId: item.related_project_id || undefined, + } + }) setMessages(mapped) } catch { // 加载失败保持 mock 数据 diff --git a/frontend/app/brand/messages/page.tsx b/frontend/app/brand/messages/page.tsx index 17efb94..ad3336a 100644 --- a/frontend/app/brand/messages/page.tsx +++ b/frontend/app/brand/messages/page.tsx @@ -45,6 +45,10 @@ type MessageType = | 'brief_config_updated' // 代理商更新了Brief配置 | 'batch_review_done' // 批量审核完成 | 'system_notice' // 系统通知 + | 'new_task' // 新任务分配 + | 'pass' // 审核通过 + | 'reject' // 审核驳回 + | 'approve' // 审核批准 type Message = { id: string @@ -80,6 +84,10 @@ const messageConfig: Record {filteredMessages.map((message) => { - const config = messageConfig[message.type] + const config = messageConfig[message.type] || messageConfig.system_notice const Icon = config.icon const platform = message.platform ? getPlatformInfo(message.platform) : null diff --git a/frontend/app/brand/page.tsx b/frontend/app/brand/page.tsx index fac5058..3b90a4b 100644 --- a/frontend/app/brand/page.tsx +++ b/frontend/app/brand/page.tsx @@ -22,28 +22,29 @@ import { api } from '@/lib/api' import { USE_MOCK } from '@/contexts/AuthContext' import { useSSE } from '@/contexts/SSEContext' import { useToast } from '@/components/ui/Toast' +import { getPlatformInfo } from '@/lib/platforms' import type { ProjectResponse } from '@/types/project' // ==================== Mock 数据 ==================== const mockProjects: ProjectResponse[] = [ { id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌', - status: 'active', deadline: '2026-06-18', agencies: [], + platform: 'douyin', status: 'active', deadline: '2026-06-18', agencies: [], task_count: 20, created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-05T00:00:00Z', }, { id: 'proj-002', name: '新品口红系列', brand_id: 'br-001', brand_name: 'XX品牌', - status: 'active', deadline: '2026-03-15', agencies: [], + platform: 'xiaohongshu', status: 'active', deadline: '2026-03-15', agencies: [], task_count: 12, created_at: '2026-01-15T00:00:00Z', updated_at: '2026-02-01T00:00:00Z', }, { id: 'proj-003', name: '护肤品秋季活动', brand_id: 'br-001', brand_name: 'XX品牌', - status: 'completed', deadline: '2025-11-30', agencies: [], + platform: 'bilibili', status: 'completed', deadline: '2025-11-30', agencies: [], task_count: 15, created_at: '2025-08-01T00:00:00Z', updated_at: '2025-11-30T00:00:00Z', }, { id: 'proj-004', name: '双11预热活动', brand_id: 'br-001', brand_name: 'XX品牌', - status: 'active', deadline: '2026-11-11', agencies: [], + platform: 'kuaishou', status: 'active', deadline: '2026-11-11', agencies: [], task_count: 18, created_at: '2026-01-10T00:00:00Z', updated_at: '2026-02-04T00:00:00Z', }, ] @@ -58,11 +59,25 @@ function StatusTag({ status }: { status: string }) { } function ProjectCard({ project, onEditDeadline }: { project: ProjectResponse; onEditDeadline: (project: ProjectResponse) => void }) { + const platformInfo = project.platform ? getPlatformInfo(project.platform) : null + return ( -
- {project.brand_name || '品牌项目'} +
+ + {platformInfo ? ( + <>{platformInfo.icon}{platformInfo.name} + ) : ( + project.brand_name || '品牌项目' + )} +
diff --git a/frontend/app/brand/projects/[id]/config/page.tsx b/frontend/app/brand/projects/[id]/config/page.tsx index 24ecded..0330a3d 100644 --- a/frontend/app/brand/projects/[id]/config/page.tsx +++ b/frontend/app/brand/projects/[id]/config/page.tsx @@ -13,6 +13,7 @@ import { Plus, Trash2, AlertTriangle, + AlertCircle, CheckCircle, Bot, Users, @@ -21,15 +22,27 @@ import { ChevronDown, ChevronUp, Loader2, - Search + Search, + RotateCcw } from 'lucide-react' import { Modal } from '@/components/ui/Modal' import { api } from '@/lib/api' import { USE_MOCK, useAuth } from '@/contexts/AuthContext' import type { RuleConflict } from '@/types/rules' -import { useOSSUpload } from '@/hooks/useOSSUpload' import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief' +// 单个文件的上传状态 +interface UploadFileItem { + id: string + name: string + size: string + status: 'uploading' | 'success' | 'error' + progress: number + url?: string + error?: string + file?: File +} + // ==================== Mock 数据 ==================== const mockBrief: BriefResponse = { id: 'bf-001', @@ -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 = [ { value: 'low', label: '宽松', description: '仅检测明显违规内容' }, @@ -114,7 +134,9 @@ export default function ProjectConfigPage() { const toast = useToast() const { user } = useAuth() const projectId = params.id as string - const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general') + + // 附件上传跟踪 + const [uploadingFiles, setUploadingFiles] = useState([]) // Brief state const [briefExists, setBriefExists] = useState(false) @@ -334,32 +356,71 @@ export default function ProjectConfigPage() { setCompetitors(competitors.filter(c => c !== name)) } - // Attachment upload - const handleAttachmentUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - + // 上传单个附件(独立跟踪进度) + const uploadSingleAttachment = async (file: File, fileId: string) => { if (USE_MOCK) { - setAttachments([...attachments, { - id: `att-${Date.now()}`, - name: file.name, - url: `mock://${file.name}`, - }]) + for (let p = 20; p <= 80; p += 20) { + await new Promise(r => setTimeout(r, 300)) + setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f)) + } + await new Promise(r => setTimeout(r, 300)) + const att: BriefAttachment = { id: fileId, name: file.name, url: `mock://${file.name}`, size: formatFileSize(file.size) } + setAttachments(prev => [...prev, att]) + setUploadingFiles(prev => prev.filter(f => f.id !== fileId)) return } try { - const result = await upload(file) - setAttachments([...attachments, { - id: `att-${Date.now()}`, - name: file.name, - url: result.url, - }]) - } catch { - toast.error('文件上传失败') + const result = await api.proxyUpload(file, 'general', (pct) => { + setUploadingFiles(prev => prev.map(f => f.id === fileId + ? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) } + : f + )) + }) + const att: BriefAttachment = { id: fileId, name: file.name, url: result.url, size: formatFileSize(file.size) } + setAttachments(prev => [...prev, att]) + setUploadingFiles(prev => prev.filter(f => f.id !== fileId)) + } catch (err) { + const msg = err instanceof Error ? err.message : '上传失败' + setUploadingFiles(prev => prev.map(f => f.id === fileId + ? { ...f, status: 'error', error: msg } + : f + )) } } + const handleAttachmentUpload = (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) return + + const fileList = Array.from(files) + e.target.value = '' + const newItems: UploadFileItem[] = fileList.map(file => ({ + id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: file.name, + size: formatFileSize(file.size), + status: 'uploading' as const, + progress: 0, + file, + })) + setUploadingFiles(prev => [...prev, ...newItems]) + newItems.forEach(item => uploadSingleAttachment(item.file!, item.id)) + } + + const retryAttachmentUpload = (fileId: string) => { + const item = uploadingFiles.find(f => f.id === fileId) + if (!item?.file) return + setUploadingFiles(prev => prev.map(f => f.id === fileId + ? { ...f, status: 'uploading', progress: 0, error: undefined } + : f + )) + uploadSingleAttachment(item.file, fileId) + } + + const removeUploadingFile = (id: string) => { + setUploadingFiles(prev => prev.filter(f => f.id !== id)) + } + const removeAttachment = (id: string) => { setAttachments(attachments.filter(a => a.id !== id)) } @@ -629,40 +690,99 @@ export default function ProjectConfigPage() { {/* 参考资料 */}
-
- {attachments.map((att) => ( -
- - {att.name} - {att.size && {att.size}} - + + + + {/* 文件列表 */} +
+
+ + + 附件列表 + + + {attachments.length + uploadingFiles.filter(f => f.status === 'uploading').length} 个文件 + {uploadingFiles.some(f => f.status === 'uploading') && ( + · 上传中 + )} + +
+ + {attachments.length === 0 && uploadingFiles.length === 0 ? ( +
+

暂无附件

- ))} - + ) : ( +
+ {/* 已完成的文件 */} + {attachments.map((att) => ( +
+ + + {att.name} + {att.size && {att.size}} + +
+ ))} + + {/* 上传中/失败的文件 */} + {uploadingFiles.map((file) => ( +
+
+ {file.status === 'uploading' && ( + + )} + {file.status === 'error' && ( + + )} + + {file.name} + + {file.status === 'uploading' ? `${file.progress}%` : file.size} + + {file.status === 'error' && ( + + )} + {file.status !== 'uploading' && ( + + )} +
+ {file.status === 'uploading' && ( +
+
+
+ )} + {file.status === 'error' && file.error && ( +

{file.error}

+ )} +
+ ))} +
+ )}
diff --git a/frontend/app/brand/projects/create/page.tsx b/frontend/app/brand/projects/create/page.tsx index c992e71..fcf548a 100644 --- a/frontend/app/brand/projects/create/page.tsx +++ b/frontend/app/brand/projects/create/page.tsx @@ -12,17 +12,38 @@ import { Calendar, FileText, CheckCircle, - X, - Users, + AlertCircle, Search, Building2, - Check, - Loader2 + Loader2, + Trash2, + RotateCcw } from 'lucide-react' import { api } from '@/lib/api' import { USE_MOCK } from '@/contexts/AuthContext' -import { useOSSUpload } from '@/hooks/useOSSUpload' +import { platformOptions } from '@/lib/platforms' import type { AgencyDetail } from '@/types/organization' +import type { BriefAttachment } from '@/types/brief' + +// 单个文件的上传状态 +interface UploadFileItem { + id: string + name: string + size: string + rawSize: number + status: 'uploading' | 'success' | 'error' + progress: number + url?: string + error?: string + file?: File // 保留引用用于重试 +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return bytes + 'B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB' + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB' + return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB' +} // ==================== Mock 数据 ==================== const mockAgencies: AgencyDetail[] = [ @@ -37,19 +58,25 @@ const mockAgencies: AgencyDetail[] = [ export default function CreateProjectPage() { const router = useRouter() const toast = useToast() - const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general') const [projectName, setProjectName] = useState('') const [description, setDescription] = useState('') + const [platform, setPlatform] = useState('douyin') const [deadline, setDeadline] = useState('') - const [briefFile, setBriefFile] = useState(null) - const [briefFileUrl, setBriefFileUrl] = useState(null) + const [uploadFiles, setUploadFiles] = useState([]) const [selectedAgencies, setSelectedAgencies] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) const [agencySearch, setAgencySearch] = useState('') const [agencies, setAgencies] = useState([]) const [loadingAgencies, setLoadingAgencies] = useState(true) + // 从成功上传的文件中提取 BriefAttachment + const briefFiles: BriefAttachment[] = uploadFiles + .filter(f => f.status === 'success' && f.url) + .map(f => ({ id: f.id, name: f.name, url: f.url!, size: f.size })) + + const hasUploading = uploadFiles.some(f => f.status === 'uploading') + useEffect(() => { const loadAgencies = async () => { if (USE_MOCK) { @@ -76,22 +103,85 @@ export default function CreateProjectPage() { agency.id.toLowerCase().includes(agencySearch.toLowerCase()) ) - const handleFileChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - setBriefFile(file) - - if (!USE_MOCK) { - try { - const result = await upload(file) - setBriefFileUrl(result.url) - } catch (err) { - toast.error('文件上传失败') - setBriefFile(null) + // 上传单个文件(独立跟踪进度) + const uploadSingleFile = async (file: File, fileId: string) => { + if (USE_MOCK) { + // Mock:模拟进度 + for (let p = 20; p <= 80; p += 20) { + await new Promise(r => setTimeout(r, 300)) + setUploadFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f)) } - } else { - setBriefFileUrl('mock://brief-file.pdf') + await new Promise(r => setTimeout(r, 300)) + setUploadFiles(prev => prev.map(f => f.id === fileId + ? { ...f, status: 'success', progress: 100, url: `mock://${file.name}` } + : f + )) + toast.success(`${file.name} 上传完成`) + return } + + try { + const result = await api.proxyUpload(file, 'general', (pct) => { + setUploadFiles(prev => prev.map(f => f.id === fileId + ? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) } + : f + )) + }) + setUploadFiles(prev => prev.map(f => f.id === fileId + ? { ...f, status: 'success', progress: 100, url: result.url } + : f + )) + toast.success(`${file.name} 上传完成`) + } catch (err) { + const msg = err instanceof Error ? err.message : '上传失败' + setUploadFiles(prev => prev.map(f => f.id === fileId + ? { ...f, status: 'error', error: msg } + : f + )) + toast.error(`${file.name} 上传失败: ${msg}`) + } + } + + const handleFileChange = (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) return + + const fileList = Array.from(files) + e.target.value = '' + toast.info(`已选择 ${fileList.length} 个文件,开始上传...`) + + // 立即添加所有文件到列表(uploading 状态) + const newItems: UploadFileItem[] = fileList.map(file => ({ + id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: file.name, + size: formatFileSize(file.size), + rawSize: file.size, + status: 'uploading' as const, + progress: 0, + file, + })) + + setUploadFiles(prev => [...prev, ...newItems]) + + // 并发上传所有文件 + newItems.forEach(item => { + uploadSingleFile(item.file!, item.id) + }) + } + + // 重试失败的上传 + const retryUpload = (fileId: string) => { + const item = uploadFiles.find(f => f.id === fileId) + if (!item?.file) return + setUploadFiles(prev => prev.map(f => f.id === fileId + ? { ...f, status: 'uploading', progress: 0, error: undefined } + : f + )) + uploadSingleFile(item.file, fileId) + } + + const removeFile = (id: string) => { + setUploadFiles(prev => prev.filter(f => f.id !== id)) } const toggleAgency = (agencyId: string) => { @@ -116,15 +206,15 @@ export default function CreateProjectPage() { const project = await api.createProject({ name: projectName.trim(), description: description.trim() || undefined, + platform, deadline, agency_ids: selectedAgencies, }) - // If brief file was uploaded, create brief - if (briefFileUrl && briefFile) { + // If brief files were uploaded, create brief with attachments + if (briefFiles.length > 0) { await api.createBrief(project.id, { - file_url: briefFileUrl, - file_name: briefFile.name, + attachments: briefFiles, }) } } @@ -177,6 +267,35 @@ export default function CreateProjectPage() { />
+ {/* 发布平台 */} +
+ +
+ {platformOptions.map((p) => { + const isSelected = platform === p.id + return ( + + ) + })} +
+
+ {/* 截止日期 */}