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
# Database data
backend/data/
# Virtual environment
venv/
# Logs
*.log
npm-debug.log*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,7 @@ from app.schemas.project import (
AgencySummary,
)
from app.services.auth import generate_id
from app.services.message_service import create_message
router = APIRouter(prefix="/projects", tags=["项目"])
@ -46,6 +47,7 @@ async def _project_to_response(project: Project, db: AsyncSession) -> ProjectRes
id=project.id,
name=project.name,
description=project.description,
platform=project.platform,
brand_id=project.brand_id,
brand_name=project.brand.name if project.brand else None,
status=project.status,
@ -72,6 +74,7 @@ async def create_project(
brand_id=brand.id,
name=request.name,
description=request.description,
platform=request.platform,
start_date=request.start_date,
deadline=request.deadline,
status="active",
@ -79,7 +82,7 @@ async def create_project(
db.add(project)
await db.flush()
# 分配代理商
# 分配代理商(直接 INSERT 关联表,避免 async 懒加载问题)
if request.agency_ids:
for agency_id in request.agency_ids:
result = await db.execute(
@ -87,7 +90,12 @@ async def create_project(
)
agency = result.scalar_one_or_none()
if agency:
project.agencies.append(agency)
await db.execute(
project_agency_association.insert().values(
project_id=project.id,
agency_id=agency.id,
)
)
await db.flush()
await db.refresh(project)
@ -100,6 +108,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:

View File

@ -558,7 +558,20 @@ 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:
# 先检查是否为图片型 PDF
image_b64_list = await DocumentParser.download_and_get_images(
request.document_url, request.document_name,
) or []
except Exception as e:
logger.warning(f"图片 PDF 检测失败,回退文本模式: {e}")
if not image_b64_list:
# 非图片 PDF 或检测失败,走文本提取
try:
document_text = await DocumentParser.download_and_parse(
request.document_url, request.document_name,
@ -572,7 +585,12 @@ async def parse_platform_rule_document(
if not document_text.strip():
raise HTTPException(status_code=400, detail="文档内容为空,无法解析")
# 2. AI 解析
# 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)
@ -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 {

View File

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

View File

@ -54,7 +54,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
app.add_middleware(SecurityHeadersMiddleware)
# Rate limiting
# Rate limiting (仅生产环境启用)
if _is_production:
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
# 注册路由

View File

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

View File

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

View File

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

View File

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

View File

@ -37,7 +37,7 @@ class User(Base, TimestampMixin):
# 角色
role: Mapped[UserRole] = mapped_column(
SQLEnum(UserRole, name="user_role_enum"),
SQLEnum(UserRole, name="user_role_enum", values_callable=lambda x: [e.value for e in x]),
nullable=False,
index=True,
)

View File

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

View File

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

View File

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

View File

@ -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 = []
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:
texts.append(text)
return "\n".join(texts)
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:

View File

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

View File

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

View File

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

View File

@ -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<HTMLInputElement>(null)
// 上传中的文件跟踪
const [uploadingFiles, setUploadingFiles] = useState<UploadingFileItem[]>([])
// 加载状态
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) => {
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))
}
setAgencyConfig(prev => ({
...prev,
agencyFiles: [...prev.agencyFiles, newFile]
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
}
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<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)
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) => {
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() {
<Eye size={14} />
</Button>
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
<Button size="sm" onClick={() => handleUploadAgencyFile()} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
@ -759,11 +876,51 @@ export default function BriefConfigPage() {
</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
type="button"
onClick={handleUploadAgencyFile}
disabled={isUploading}
onClick={() => handleUploadAgencyFile()}
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">
@ -1060,7 +1217,7 @@ export default function BriefConfigPage() {
<p className="text-sm text-text-secondary">
</p>
<Button size="sm" onClick={handleUploadAgencyFile} disabled={isUploading}>
<Button size="sm" onClick={() => handleUploadAgencyFile()} disabled={isUploading}>
<Upload size={14} />
{isUploading ? '上传中...' : '上传文档'}
</Button>
@ -1136,6 +1293,15 @@ export default function BriefConfigPage() {
</div>
</Modal>
{/* 隐藏的文件上传 input */}
<input
ref={agencyFileInputRef}
type="file"
multiple
onChange={handleUploadAgencyFile}
className="hidden"
/>
{/* 规则冲突检测结果弹窗 */}
<Modal
isOpen={showConflictModal}

View File

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

View File

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

View File

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

View File

@ -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<UploadFileItem[]>([])
// 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<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 上传单个附件(独立跟踪进度)
const uploadSingleAttachment = async (file: File, fileId: string) => {
if (USE_MOCK) {
setAttachments([...attachments, {
id: `att-${Date.now()}`,
name: file.name,
url: `mock://${file.name}`,
}])
for (let p = 20; p <= 80; p += 20) {
await new Promise(r => setTimeout(r, 300))
setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
}
await new Promise(r => setTimeout(r, 300))
const att: BriefAttachment = { id: fileId, name: file.name, url: `mock://${file.name}`, size: formatFileSize(file.size) }
setAttachments(prev => [...prev, att])
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
return
}
try {
const result = await upload(file)
setAttachments([...attachments, {
id: `att-${Date.now()}`,
name: file.name,
url: result.url,
}])
} catch {
toast.error('文件上传失败')
const result = await api.proxyUpload(file, 'general', (pct) => {
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) }
: f
))
})
const att: BriefAttachment = { id: fileId, name: file.name, url: result.url, size: formatFileSize(file.size) }
setAttachments(prev => [...prev, att])
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'error', error: msg }
: f
))
}
}
const handleAttachmentUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const fileList = Array.from(files)
e.target.value = ''
const newItems: UploadFileItem[] = fileList.map(file => ({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
size: formatFileSize(file.size),
status: 'uploading' as const,
progress: 0,
file,
}))
setUploadingFiles(prev => [...prev, ...newItems])
newItems.forEach(item => uploadSingleAttachment(item.file!, item.id))
}
const retryAttachmentUpload = (fileId: string) => {
const item = uploadingFiles.find(f => f.id === fileId)
if (!item?.file) return
setUploadingFiles(prev => prev.map(f => f.id === fileId
? { ...f, status: 'uploading', progress: 0, error: undefined }
: f
))
uploadSingleAttachment(item.file, fileId)
}
const removeUploadingFile = (id: string) => {
setUploadingFiles(prev => prev.filter(f => f.id !== id))
}
const removeAttachment = (id: string) => {
setAttachments(attachments.filter(a => a.id !== id))
}
@ -629,40 +690,99 @@ export default function ProjectConfigPage() {
{/* 参考资料 */}
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<div className="space-y-2">
<label className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-dashed border-border-subtle bg-bg-elevated text-text-primary hover:border-accent-indigo/50 hover:bg-bg-page transition-colors cursor-pointer w-full text-sm mb-3">
<Upload size={16} className="text-accent-indigo" />
<input
type="file"
multiple
onChange={handleAttachmentUpload}
className="hidden"
/>
</label>
{/* 文件列表 */}
<div className="border border-border-subtle rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-bg-elevated border-b border-border-subtle">
<span className="text-xs font-medium text-text-secondary flex items-center gap-1.5">
<FileText size={12} className="text-accent-indigo" />
</span>
<span className="text-xs text-text-tertiary">
{attachments.length + uploadingFiles.filter(f => f.status === 'uploading').length}
{uploadingFiles.some(f => f.status === 'uploading') && (
<span className="text-accent-indigo ml-1">· </span>
)}
</span>
</div>
{attachments.length === 0 && uploadingFiles.length === 0 ? (
<div className="px-4 py-5 text-center">
<p className="text-xs text-text-tertiary"></p>
</div>
) : (
<div className="divide-y divide-border-subtle">
{/* 已完成的文件 */}
{attachments.map((att) => (
<div key={att.id} className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
<FileText size={16} className="text-accent-indigo" />
<span className="flex-1 text-text-primary">{att.name}</span>
<div key={att.id} className="flex items-center gap-3 px-4 py-2.5">
<CheckCircle size={14} className="text-accent-green flex-shrink-0" />
<FileText size={14} className="text-text-tertiary flex-shrink-0" />
<span className="flex-1 text-sm text-text-primary truncate">{att.name}</span>
{att.size && <span className="text-xs text-text-tertiary">{att.size}</span>}
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="p-1 rounded hover:bg-bg-page text-text-tertiary hover:text-accent-coral transition-colors"
className="p-1 rounded hover:bg-bg-elevated text-text-tertiary hover:text-accent-coral transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
<label className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-border-subtle bg-bg-elevated text-text-primary hover:bg-bg-page transition-colors cursor-pointer w-full text-sm">
{isUploading ? (
<>
<Loader2 size={16} className="animate-spin" />
{uploadProgress}%
</>
) : (
<>
<Upload size={16} />
</>
{/* 上传中/失败的文件 */}
{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>
)}
<input
type="file"
onChange={handleAttachmentUpload}
className="hidden"
disabled={isUploading}
/>
</label>
</div>
</div>
</CardContent>

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,6 @@ import { Modal } from '@/components/ui/Modal'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { useSSE } from '@/contexts/SSEContext'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import type { TaskResponse, AIReviewResult } from '@/types/task'
import type { BriefResponse } from '@/types/brief'
@ -217,64 +216,109 @@ function AgencyBriefSection({ toast, briefData }: { toast: ReturnType<typeof use
function UploadSection({ taskId, onUploaded }: { taskId: string; onUploaded: () => void }) {
const [file, setFile] = useState<File | null>(null)
const { upload, isUploading, progress } = useOSSUpload('script')
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
const toast = useToast()
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) setFile(selectedFile)
if (selectedFile) {
setFile(selectedFile)
setUploadError(null)
}
}
const handleSubmit = async () => {
if (!file) return
setIsUploading(true)
setProgress(0)
setUploadError(null)
try {
const result = await upload(file)
if (!USE_MOCK) {
await api.uploadTaskScript(taskId, { file_url: result.url, file_name: result.file_name })
if (USE_MOCK) {
for (let i = 0; i <= 100; i += 20) {
await new Promise(r => setTimeout(r, 400))
setProgress(i)
}
toast.success('脚本已提交,等待 AI 审核')
onUploaded()
} catch (err) {
toast.error(err instanceof Error ? err.message : '上传失败')
} else {
const result = await api.proxyUpload(file, 'script', (pct) => {
setProgress(Math.min(90, Math.round(pct * 0.9)))
})
setProgress(95)
await api.uploadTaskScript(taskId, { file_url: result.url, file_name: result.file_name })
setProgress(100)
toast.success('脚本已提交,等待 AI 审核')
onUploaded()
}
} catch (err) {
const msg = err instanceof Error ? err.message : '上传失败'
setUploadError(msg)
toast.error(msg)
} finally {
setIsUploading(false)
}
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
}
return (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Upload size={18} className="text-accent-indigo" /></CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{file ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-3">
<FileText size={24} className="text-accent-indigo" />
<span className="text-text-primary">{file.name}</span>
{!file ? (
<label className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"></p>
<p className="text-xs text-text-tertiary"> WordPDFTXT </p>
<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 && (
<button type="button" onClick={() => setFile(null)} className="p-1 hover:bg-bg-elevated rounded-full">
<XCircle size={16} className="text-text-tertiary" />
<button type="button" onClick={() => { setFile(null); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
<XCircle size={14} className="text-text-tertiary" />
</button>
)}
</div>
{isUploading && (
<div className="w-full max-w-xs mx-auto">
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
<div className="h-full bg-accent-indigo transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-sm text-text-tertiary"> {progress}%</p>
<div className="mt-2 ml-[30px] h-2 bg-bg-page rounded-full overflow-hidden">
<div className="h-full bg-accent-indigo rounded-full transition-all duration-300" style={{ width: `${progress}%` }} />
</div>
)}
</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>
{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>
)}
<Button onClick={handleSubmit} disabled={!file || isUploading} fullWidth>
{isUploading ? '上传中...' : '提交脚本'}
{isUploading ? (
<><Loader2 size={16} className="animate-spin" /> {progress}%</>
) : '提交脚本'}
</Button>
</CardContent>
</Card>

View File

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

View File

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

View File

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

View File

@ -453,6 +453,23 @@ class ApiClient {
return response.data
}
/**
* TOS CORS/
*/
async proxyUpload(file: File, fileType: string = 'general', onProgress?: (pct: number) => void): Promise<FileUploadedResponse> {
const formData = new FormData()
formData.append('file', file)
formData.append('file_type', fileType)
const response = await this.client.post<FileUploadedResponse>('/upload/proxy', formData, {
timeout: 300000,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total && onProgress) onProgress(Math.round((e.loaded / e.total) * 100))
},
})
return response.data
}
/**
* 访 URL
*/
@ -877,7 +894,9 @@ class ApiClient {
* AI
*/
async parsePlatformRule(data: PlatformRuleParseRequest): Promise<PlatformRuleParseResponse> {
const response = await this.client.post<PlatformRuleParseResponse>('/rules/platform-rules/parse', data)
const response = await this.client.post<PlatformRuleParseResponse>('/rules/platform-rules/parse', data, {
timeout: 180000, // 3 分钟,视觉模型解析图片 PDF 较慢
})
return response.data
}

View File

@ -34,6 +34,7 @@ export interface BriefResponse {
max_duration?: number | null
other_requirements?: string | null
attachments?: BriefAttachment[] | null
agency_attachments?: BriefAttachment[] | null
created_at: string
updated_at: string
}
@ -49,4 +50,5 @@ export interface BriefCreateRequest {
max_duration?: number
other_requirements?: string
attachments?: BriefAttachment[]
agency_attachments?: BriefAttachment[]
}

View File

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

View File

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