Compare commits
20
Commits
68dac332d4
...
main
@@ -42,6 +42,12 @@ Thumbs.db
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Database data
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
venv/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
stages:
|
||||||
|
- lint
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
|
||||||
|
variables:
|
||||||
|
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||||
|
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.cache/npm"
|
||||||
|
|
||||||
|
# ─── Backend Jobs ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
backend-lint:
|
||||||
|
stage: lint
|
||||||
|
image: python:3.12-slim
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- merge_requests
|
||||||
|
cache:
|
||||||
|
key: backend-pip
|
||||||
|
paths:
|
||||||
|
- .cache/pip
|
||||||
|
script:
|
||||||
|
- cd backend
|
||||||
|
- python3 -c "
|
||||||
|
import ast, sys, pathlib;
|
||||||
|
ok = True;
|
||||||
|
for p in sorted(pathlib.Path('.').rglob('*.py')):
|
||||||
|
try:
|
||||||
|
ast.parse(p.read_text());
|
||||||
|
except SyntaxError as e:
|
||||||
|
print(f'SyntaxError in {p}\u003a {e}');
|
||||||
|
ok = False;
|
||||||
|
if not ok:
|
||||||
|
sys.exit(1);
|
||||||
|
print(f'All {len(list(pathlib.Path(\".\").rglob(\"*.py\")))} Python files passed syntax check')
|
||||||
|
"
|
||||||
|
|
||||||
|
backend-test:
|
||||||
|
stage: test
|
||||||
|
image: python:3.12-slim
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- merge_requests
|
||||||
|
cache:
|
||||||
|
key: backend-pip
|
||||||
|
paths:
|
||||||
|
- .cache/pip
|
||||||
|
variables:
|
||||||
|
DATABASE_URL: "sqlite+aiosqlite:///./test.db"
|
||||||
|
SECRET_KEY: "ci-test-secret-key"
|
||||||
|
script:
|
||||||
|
- cd backend
|
||||||
|
- pip install -e ".[dev]" aiosqlite "bcrypt<5" --quiet
|
||||||
|
- pytest tests/ -x -q --tb=short
|
||||||
|
|
||||||
|
# ─── Frontend Jobs ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
.frontend-base:
|
||||||
|
image: node:20-slim
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- merge_requests
|
||||||
|
cache:
|
||||||
|
key: frontend-npm
|
||||||
|
paths:
|
||||||
|
- .cache/npm
|
||||||
|
- frontend/node_modules
|
||||||
|
before_script:
|
||||||
|
- cd frontend
|
||||||
|
- npm ci --prefer-offline
|
||||||
|
|
||||||
|
frontend-lint:
|
||||||
|
extends: .frontend-base
|
||||||
|
stage: lint
|
||||||
|
script:
|
||||||
|
- npm run lint
|
||||||
|
|
||||||
|
frontend-typecheck:
|
||||||
|
extends: .frontend-base
|
||||||
|
stage: lint
|
||||||
|
script:
|
||||||
|
- npx tsc --noEmit
|
||||||
|
|
||||||
|
frontend-test:
|
||||||
|
extends: .frontend-base
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- npm test -- --run
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
extends: .frontend-base
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- npm run build
|
||||||
@@ -106,8 +106,9 @@ useEffect(() => { loadData() }, [loadData])
|
|||||||
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
|
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
|
||||||
|
|
||||||
### 文件上传
|
### 文件上传
|
||||||
- 阿里云 OSS 直传,前端通过 `useOSSUpload` hook 处理
|
- 火山引擎 TOS 直传,前端通过 `useOSSUpload` hook 处理
|
||||||
- 流程:`api.getUploadPolicy()` → POST 到 OSS → `api.fileUploaded()` 回调
|
- 流程:`api.getUploadPolicy()` → POST 到 TOS → `api.fileUploaded()` 回调
|
||||||
|
- TOS V4 签名:HMAC-SHA256,字段包括 `x-tos-algorithm`、`x-tos-credential`、`x-tos-date`、`x-tos-signature`、`policy`
|
||||||
|
|
||||||
### 实时推送
|
### 实时推送
|
||||||
- SSE (Server-Sent Events),端点 `/api/v1/sse/events`
|
- SSE (Server-Sent Events),端点 `/api/v1/sse/events`
|
||||||
|
|||||||
+21
-8
@@ -8,12 +8,16 @@
|
|||||||
APP_NAME=秒思智能审核平台
|
APP_NAME=秒思智能审核平台
|
||||||
APP_VERSION=1.0.0
|
APP_VERSION=1.0.0
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
ENVIRONMENT=production
|
||||||
|
|
||||||
# --- 数据库 ---
|
# --- 数据库 ---
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi
|
POSTGRES_USER=miaosi
|
||||||
|
POSTGRES_PASSWORD=change-me-in-production
|
||||||
|
POSTGRES_DB=miaosi
|
||||||
|
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||||
|
|
||||||
# --- Redis ---
|
# --- Redis ---
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
# --- JWT ---
|
# --- JWT ---
|
||||||
# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||||
@@ -26,12 +30,21 @@ AI_PROVIDER=oneapi
|
|||||||
AI_API_KEY=
|
AI_API_KEY=
|
||||||
AI_API_BASE_URL=
|
AI_API_BASE_URL=
|
||||||
|
|
||||||
# --- 阿里云 OSS ---
|
# --- 火山引擎 TOS ---
|
||||||
OSS_ACCESS_KEY_ID=
|
TOS_ACCESS_KEY_ID=
|
||||||
OSS_ACCESS_KEY_SECRET=
|
TOS_SECRET_ACCESS_KEY=
|
||||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
TOS_REGION=cn-beijing
|
||||||
OSS_BUCKET_NAME=miaosi-files
|
TOS_BUCKET_NAME=miaosi-files
|
||||||
OSS_BUCKET_DOMAIN=
|
TOS_ENDPOINT=
|
||||||
|
TOS_CDN_DOMAIN=
|
||||||
|
|
||||||
|
# --- 邮件 SMTP ---
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM_NAME=秒思智能审核平台
|
||||||
|
SMTP_USE_SSL=true
|
||||||
|
|
||||||
# --- 加密密钥 ---
|
# --- 加密密钥 ---
|
||||||
# 用于加密存储 API 密钥等敏感数据
|
# 用于加密存储 API 密钥等敏感数据
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ COPY app/ ./app/
|
|||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
|
|
||||||
# 创建非 root 用户
|
# 创建非 root 用户
|
||||||
RUN groupadd -r miaosi && useradd -r -g miaosi -d /app -s /sbin/nologin miaosi \
|
RUN groupadd -r miaosi && useradd -r -g miaosi -d /app -s /sbin/nologin miaosi \
|
||||||
@@ -53,4 +54,5 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD curl -f http://localhost:8000/health || exit 1
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["./scripts/entrypoint.sh"]
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -14,14 +14,8 @@ from alembic import context
|
|||||||
# 导入配置和模型
|
# 导入配置和模型
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
from app.models import (
|
# 导入所有模型,确保 autogenerate 能检测到全部表
|
||||||
Tenant,
|
from app.models import * # noqa: F401,F403
|
||||||
AIConfig,
|
|
||||||
ReviewTask,
|
|
||||||
ForbiddenWord,
|
|
||||||
WhitelistItem,
|
|
||||||
Competitor,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Alembic Config 对象
|
# Alembic Config 对象
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|||||||
@@ -22,13 +22,15 @@ def upgrade() -> None:
|
|||||||
# 创建枚举类型
|
# 创建枚举类型
|
||||||
platform_enum = postgresql.ENUM(
|
platform_enum = postgresql.ENUM(
|
||||||
'douyin', 'xiaohongshu', 'bilibili', 'kuaishou',
|
'douyin', 'xiaohongshu', 'bilibili', 'kuaishou',
|
||||||
name='platform_enum'
|
name='platform_enum',
|
||||||
|
create_type=False,
|
||||||
)
|
)
|
||||||
platform_enum.create(op.get_bind(), checkfirst=True)
|
platform_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
task_status_enum = postgresql.ENUM(
|
task_status_enum = postgresql.ENUM(
|
||||||
'pending', 'processing', 'completed', 'failed', 'approved', 'rejected',
|
'pending', 'processing', 'completed', 'failed', 'approved', 'rejected',
|
||||||
name='task_status_enum'
|
name='task_status_enum',
|
||||||
|
create_type=False,
|
||||||
)
|
)
|
||||||
task_status_enum.create(op.get_bind(), checkfirst=True)
|
task_status_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
|||||||
@@ -17,38 +17,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
# 原 manual_tasks 表已废弃,字段已合并到 003 的 tasks 表中
|
||||||
"manual_tasks",
|
pass
|
||||||
sa.Column("video_uploaded_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.alter_column(
|
|
||||||
"manual_tasks",
|
|
||||||
"video_url",
|
|
||||||
existing_type=sa.String(length=2048),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"manual_tasks",
|
|
||||||
sa.Column("script_content", sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"manual_tasks",
|
|
||||||
sa.Column("script_file_url", sa.String(length=2048), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"manual_tasks",
|
|
||||||
sa.Column("script_uploaded_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_column("manual_tasks", "script_uploaded_at")
|
pass
|
||||||
op.drop_column("manual_tasks", "script_file_url")
|
|
||||||
op.drop_column("manual_tasks", "script_content")
|
|
||||||
op.alter_column(
|
|
||||||
"manual_tasks",
|
|
||||||
"video_url",
|
|
||||||
existing_type=sa.String(length=2048),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
op.drop_column("manual_tasks", "video_uploaded_at")
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ def upgrade() -> None:
|
|||||||
# 创建枚举类型
|
# 创建枚举类型
|
||||||
user_role_enum = postgresql.ENUM(
|
user_role_enum = postgresql.ENUM(
|
||||||
'brand', 'agency', 'creator',
|
'brand', 'agency', 'creator',
|
||||||
name='user_role_enum'
|
name='user_role_enum',
|
||||||
|
create_type=False,
|
||||||
)
|
)
|
||||||
user_role_enum.create(op.get_bind(), checkfirst=True)
|
user_role_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
@@ -30,10 +31,15 @@ def upgrade() -> None:
|
|||||||
'script_upload', 'script_ai_review', 'script_agency_review', 'script_brand_review',
|
'script_upload', 'script_ai_review', 'script_agency_review', 'script_brand_review',
|
||||||
'video_upload', 'video_ai_review', 'video_agency_review', 'video_brand_review',
|
'video_upload', 'video_ai_review', 'video_agency_review', 'video_brand_review',
|
||||||
'completed', 'rejected',
|
'completed', 'rejected',
|
||||||
name='task_stage_enum'
|
name='task_stage_enum',
|
||||||
|
create_type=False,
|
||||||
)
|
)
|
||||||
task_stage_enum.create(op.get_bind(), checkfirst=True)
|
task_stage_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
# 扩展 task_status_enum:添加 Task 模型需要的值
|
||||||
|
op.execute("ALTER TYPE task_status_enum ADD VALUE IF NOT EXISTS 'passed'")
|
||||||
|
op.execute("ALTER TYPE task_status_enum ADD VALUE IF NOT EXISTS 'force_passed'")
|
||||||
|
|
||||||
# 用户表
|
# 用户表
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'users',
|
'users',
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""添加审计日志表
|
||||||
|
|
||||||
|
Revision ID: 004
|
||||||
|
Revises: 003
|
||||||
|
Create Date: 2026-02-09
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '004'
|
||||||
|
down_revision: Union[str, None] = '003'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'audit_logs',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('action', sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column('resource_type', sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column('resource_id', sa.String(64), nullable=True, index=True),
|
||||||
|
sa.Column('user_id', sa.String(64), nullable=True, index=True),
|
||||||
|
sa.Column('user_name', sa.String(255), nullable=True),
|
||||||
|
sa.Column('user_role', sa.String(20), nullable=True),
|
||||||
|
sa.Column('detail', sa.Text(), nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(45), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('audit_logs')
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""添加消息表
|
||||||
|
|
||||||
|
Revision ID: 005
|
||||||
|
Revises: 004
|
||||||
|
Create Date: 2026-02-09
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '005'
|
||||||
|
down_revision: Union[str, None] = '004'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'messages',
|
||||||
|
sa.Column('id', sa.String(64), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.String(64), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('title', sa.String(255), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('is_read', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('related_task_id', sa.String(64), nullable=True),
|
||||||
|
sa.Column('related_project_id', sa.String(64), nullable=True),
|
||||||
|
sa.Column('sender_name', sa.String(100), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index('idx_messages_user_id', 'messages', ['user_id'])
|
||||||
|
op.create_index('idx_messages_user_read', 'messages', ['user_id', 'is_read'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('idx_messages_user_read', table_name='messages')
|
||||||
|
op.drop_index('idx_messages_user_id', table_name='messages')
|
||||||
|
op.drop_table('messages')
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""添加平台规则表
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005
|
||||||
|
Create Date: 2026-02-10
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '006'
|
||||||
|
down_revision: Union[str, None] = '005'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'platform_rules',
|
||||||
|
sa.Column('id', sa.String(64), primary_key=True),
|
||||||
|
sa.Column('tenant_id', sa.String(64), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||||
|
sa.Column('brand_id', sa.String(64), nullable=False, index=True),
|
||||||
|
sa.Column('platform', sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column('document_url', sa.String(2048), nullable=False),
|
||||||
|
sa.Column('document_name', sa.String(512), nullable=False),
|
||||||
|
sa.Column('parsed_rules', sa.JSON().with_variant(postgresql.JSONB, 'postgresql'), nullable=True),
|
||||||
|
sa.Column('status', sa.String(20), nullable=False, default='draft', index=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('platform_rules')
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""add min_selling_points to briefs
|
||||||
|
|
||||||
|
Revision ID: 261778c01ef8
|
||||||
|
Revises: 008
|
||||||
|
Create Date: 2026-02-11 18:16:59.557746
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '261778c01ef8'
|
||||||
|
down_revision: Union[str, None] = '008'
|
||||||
|
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('min_selling_points', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('briefs', 'min_selling_points')
|
||||||
+295
-1
@@ -1,8 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Brief API
|
Brief API
|
||||||
项目 Brief 文档的 CRUD
|
项目 Brief 文档的 CRUD + AI 解析
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -16,10 +20,13 @@ from app.api.deps import get_current_user
|
|||||||
from app.schemas.brief import (
|
from app.schemas.brief import (
|
||||||
BriefCreateRequest,
|
BriefCreateRequest,
|
||||||
BriefUpdateRequest,
|
BriefUpdateRequest,
|
||||||
|
AgencyBriefUpdateRequest,
|
||||||
BriefResponse,
|
BriefResponse,
|
||||||
)
|
)
|
||||||
from app.services.auth import generate_id
|
from app.services.auth import generate_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/brief", tags=["Brief"])
|
router = APIRouter(prefix="/projects/{project_id}/brief", tags=["Brief"])
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +81,7 @@ def _brief_to_response(brief: Brief) -> BriefResponse:
|
|||||||
file_url=brief.file_url,
|
file_url=brief.file_url,
|
||||||
file_name=brief.file_name,
|
file_name=brief.file_name,
|
||||||
selling_points=brief.selling_points,
|
selling_points=brief.selling_points,
|
||||||
|
min_selling_points=brief.min_selling_points,
|
||||||
blacklist_words=brief.blacklist_words,
|
blacklist_words=brief.blacklist_words,
|
||||||
competitors=brief.competitors,
|
competitors=brief.competitors,
|
||||||
brand_tone=brief.brand_tone,
|
brand_tone=brief.brand_tone,
|
||||||
@@ -81,6 +89,7 @@ def _brief_to_response(brief: Brief) -> BriefResponse:
|
|||||||
max_duration=brief.max_duration,
|
max_duration=brief.max_duration,
|
||||||
other_requirements=brief.other_requirements,
|
other_requirements=brief.other_requirements,
|
||||||
attachments=brief.attachments,
|
attachments=brief.attachments,
|
||||||
|
agency_attachments=brief.agency_attachments,
|
||||||
created_at=brief.created_at,
|
created_at=brief.created_at,
|
||||||
updated_at=brief.updated_at,
|
updated_at=brief.updated_at,
|
||||||
)
|
)
|
||||||
@@ -137,6 +146,7 @@ async def create_brief(
|
|||||||
max_duration=request.max_duration,
|
max_duration=request.max_duration,
|
||||||
other_requirements=request.other_requirements,
|
other_requirements=request.other_requirements,
|
||||||
attachments=request.attachments,
|
attachments=request.attachments,
|
||||||
|
agency_attachments=request.agency_attachments,
|
||||||
)
|
)
|
||||||
db.add(brief)
|
db.add(brief)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -180,3 +190,287 @@ async def update_brief(
|
|||||||
await db.refresh(brief)
|
await db.refresh(brief)
|
||||||
|
|
||||||
return _brief_to_response(brief)
|
return _brief_to_response(brief)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/agency-attachments", response_model=BriefResponse)
|
||||||
|
async def update_brief_agency_attachments(
|
||||||
|
project_id: str,
|
||||||
|
request: AgencyBriefUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""更新 Brief 代理商配置(代理商操作)
|
||||||
|
|
||||||
|
代理商可更新:agency_attachments、selling_points、blacklist_words。
|
||||||
|
不能修改品牌方设置的核心 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 不存在")
|
||||||
|
|
||||||
|
# 更新代理商可编辑的字段
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== AI 解析 ====================
|
||||||
|
|
||||||
|
class BriefParseResponse(BaseModel):
|
||||||
|
"""Brief AI 解析响应"""
|
||||||
|
product_name: str = ""
|
||||||
|
target_audience: str = ""
|
||||||
|
content_requirements: str = ""
|
||||||
|
selling_points: list[dict] = []
|
||||||
|
blacklist_words: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/parse", response_model=BriefParseResponse)
|
||||||
|
async def parse_brief_with_ai(
|
||||||
|
project_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
AI 解析 Brief 文档
|
||||||
|
|
||||||
|
从品牌方上传的 Brief 文件中提取结构化信息:
|
||||||
|
- 产品名称
|
||||||
|
- 目标人群
|
||||||
|
- 内容要求
|
||||||
|
- 卖点建议
|
||||||
|
- 违禁词建议
|
||||||
|
"""
|
||||||
|
# 权限检查(代理商需要属于该项目)
|
||||||
|
project = await _get_project_with_permission(project_id, current_user, db)
|
||||||
|
|
||||||
|
# 获取 Brief
|
||||||
|
result = await db.execute(
|
||||||
|
select(Brief)
|
||||||
|
.options(selectinload(Brief.project))
|
||||||
|
.where(Brief.project_id == project_id)
|
||||||
|
)
|
||||||
|
brief = result.scalar_one_or_none()
|
||||||
|
if not brief:
|
||||||
|
raise HTTPException(status_code=404, detail="Brief 不存在,请先让品牌方创建 Brief")
|
||||||
|
|
||||||
|
# 收集所有可解析的文档 URL
|
||||||
|
documents: list[dict] = [] # [{"url": ..., "name": ...}]
|
||||||
|
|
||||||
|
if brief.file_url and brief.file_name:
|
||||||
|
documents.append({"url": brief.file_url, "name": brief.file_name})
|
||||||
|
|
||||||
|
if brief.attachments:
|
||||||
|
for att in brief.attachments:
|
||||||
|
if att.get("url") and att.get("name"):
|
||||||
|
documents.append({"url": att["url"], "name": att["name"]})
|
||||||
|
|
||||||
|
if not documents:
|
||||||
|
raise HTTPException(status_code=400, detail="Brief 没有可解析的文件")
|
||||||
|
|
||||||
|
# 提取文本(每个文档限时 60 秒)
|
||||||
|
import asyncio
|
||||||
|
from app.services.document_parser import DocumentParser
|
||||||
|
|
||||||
|
all_texts = []
|
||||||
|
for doc in documents:
|
||||||
|
try:
|
||||||
|
text = await asyncio.wait_for(
|
||||||
|
DocumentParser.download_and_parse(doc["url"], doc["name"]),
|
||||||
|
timeout=60.0,
|
||||||
|
)
|
||||||
|
if text and text.strip():
|
||||||
|
all_texts.append(f"=== {doc['name']} ===\n{text}")
|
||||||
|
logger.info(f"成功解析文档 {doc['name']},提取 {len(text)} 字符")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"解析文档 {doc['name']} 超时(60s),已跳过")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"解析文档 {doc['name']} 失败: {e}")
|
||||||
|
|
||||||
|
if not all_texts:
|
||||||
|
raise HTTPException(status_code=400, detail="所有文档均解析失败,无法提取文本内容")
|
||||||
|
|
||||||
|
combined_text = "\n\n".join(all_texts)
|
||||||
|
|
||||||
|
# 截断过长文本
|
||||||
|
max_chars = 15000
|
||||||
|
if len(combined_text) > max_chars:
|
||||||
|
combined_text = combined_text[:max_chars] + "\n...(内容已截断)"
|
||||||
|
|
||||||
|
# 获取 AI 客户端
|
||||||
|
from app.services.ai_service import AIServiceFactory
|
||||||
|
|
||||||
|
tenant_id = project.brand_id or "default"
|
||||||
|
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||||||
|
if not ai_client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="AI 服务未配置,请在品牌方设置中配置 AI 服务",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = await AIServiceFactory.get_config(tenant_id, db)
|
||||||
|
text_model = "gpt-4o"
|
||||||
|
if config and config.models:
|
||||||
|
text_model = config.models.get("text", "gpt-4o")
|
||||||
|
|
||||||
|
# AI 解析
|
||||||
|
prompt = f"""你是营销内容合规审核专家。请从以下品牌方 Brief 文档中提取结构化信息。
|
||||||
|
|
||||||
|
文档内容:
|
||||||
|
{combined_text}
|
||||||
|
|
||||||
|
请以 JSON 格式返回,不要包含其他内容:
|
||||||
|
{{
|
||||||
|
"product_name": "产品名称",
|
||||||
|
"target_audience": "目标人群描述",
|
||||||
|
"content_requirements": "内容创作要求的简要总结",
|
||||||
|
"selling_points": [
|
||||||
|
{{"content": "卖点1", "priority": "core"}},
|
||||||
|
{{"content": "卖点2", "priority": "recommended"}},
|
||||||
|
{{"content": "卖点3", "priority": "reference"}}
|
||||||
|
],
|
||||||
|
"blacklist_words": [
|
||||||
|
{{"word": "违禁词1", "reason": "原因"}},
|
||||||
|
{{"word": "违禁词2", "reason": "原因"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- product_name: 从文档中识别的产品/品牌名称
|
||||||
|
- target_audience: 目标消费人群
|
||||||
|
- content_requirements: 对达人创作内容的要求(时长、风格、场景等)
|
||||||
|
- selling_points: 产品卖点,priority 说明:
|
||||||
|
- "core": 核心卖点,品牌方重点关注,建议优先传达
|
||||||
|
- "recommended": 推荐卖点,建议提及
|
||||||
|
- "reference": 参考信息,不要求出现在脚本中
|
||||||
|
- blacklist_words: 从文档中识别的需要避免的词语(绝对化用语、竞品名、敏感词等)"""
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
response = await ai_client.chat_completion(
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
model=text_model,
|
||||||
|
temperature=0.2 if attempt == 0 else 0.1,
|
||||||
|
max_tokens=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 提取 JSON
|
||||||
|
logger.info(f"AI 原始响应 (attempt={attempt}): {response.content[:500]}")
|
||||||
|
content = _extract_json_from_response(response.content)
|
||||||
|
logger.info(f"提取的 JSON: {content[:500]}")
|
||||||
|
parsed = json.loads(content)
|
||||||
|
|
||||||
|
return BriefParseResponse(
|
||||||
|
product_name=parsed.get("product_name", ""),
|
||||||
|
target_audience=parsed.get("target_audience", ""),
|
||||||
|
content_requirements=parsed.get("content_requirements", ""),
|
||||||
|
selling_points=parsed.get("selling_points", []),
|
||||||
|
blacklist_words=parsed.get("blacklist_words", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
last_error = e
|
||||||
|
logger.warning(f"AI 返回内容非 JSON (attempt={attempt}): {e}, raw={response.content[:300]}")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI 解析 Brief 失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"AI 解析失败: {str(e)[:200]}")
|
||||||
|
|
||||||
|
# 两次都失败
|
||||||
|
logger.error(f"AI 解析 Brief JSON 格式错误,两次重试均失败: {last_error}")
|
||||||
|
raise HTTPException(status_code=500, detail="AI 解析结果格式错误,请重试")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_from_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()
|
||||||
|
|
||||||
|
# 尝试找到第一个 { 和最后一个 }
|
||||||
|
first_brace = text.find("{")
|
||||||
|
last_brace = text.rfind("}")
|
||||||
|
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
||||||
|
text = text[first_brace:last_brace + 1]
|
||||||
|
|
||||||
|
# 清理中文引号等特殊字符
|
||||||
|
text = _sanitize_json_string(text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_json_string(text: str) -> str:
|
||||||
|
"""
|
||||||
|
清理 AI 返回的 JSON 文本中的中文引号等特殊字符。
|
||||||
|
中文引号 "" 在 JSON 字符串值内会破坏解析。
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
消息/通知 API
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.schemas.message import MessageResponse, MessageListResponse, UnreadCountResponse
|
||||||
|
from app.services.message_service import (
|
||||||
|
list_messages,
|
||||||
|
get_unread_count,
|
||||||
|
mark_as_read,
|
||||||
|
mark_all_as_read,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/messages", tags=["消息"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=MessageListResponse)
|
||||||
|
async def get_messages(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
is_read: Optional[bool] = Query(None),
|
||||||
|
type: Optional[str] = Query(None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取消息列表"""
|
||||||
|
messages, total = await list_messages(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
is_read=is_read,
|
||||||
|
type=type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MessageListResponse(
|
||||||
|
items=[
|
||||||
|
MessageResponse(
|
||||||
|
id=m.id,
|
||||||
|
type=m.type,
|
||||||
|
title=m.title,
|
||||||
|
content=m.content,
|
||||||
|
is_read=m.is_read,
|
||||||
|
related_task_id=m.related_task_id,
|
||||||
|
related_project_id=m.related_project_id,
|
||||||
|
sender_name=m.sender_name,
|
||||||
|
created_at=m.created_at,
|
||||||
|
)
|
||||||
|
for m in messages
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||||
|
async def get_message_unread_count(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取未读消息数"""
|
||||||
|
count = await get_unread_count(db, current_user.id)
|
||||||
|
return UnreadCountResponse(count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{message_id}/read")
|
||||||
|
async def mark_message_as_read(
|
||||||
|
message_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""标记消息已读"""
|
||||||
|
success = await mark_as_read(db, message_id, current_user.id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="消息不存在",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "已标记为已读"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/read-all")
|
||||||
|
async def mark_all_messages_as_read(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""标记所有消息已读"""
|
||||||
|
count = await mark_all_as_read(db, current_user.id)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": f"已标记 {count} 条消息为已读", "count": count}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
用户资料 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.organization import Brand, Agency, Creator
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.services.auth import verify_password, hash_password
|
||||||
|
from app.schemas.profile import (
|
||||||
|
ProfileResponse,
|
||||||
|
ProfileUpdateRequest,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
BrandProfile,
|
||||||
|
AgencyProfile,
|
||||||
|
CreatorProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/profile", tags=["用户资料"])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_profile_response(user: User, brand=None, agency=None, creator=None) -> ProfileResponse:
|
||||||
|
"""构建资料响应"""
|
||||||
|
resp = ProfileResponse(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
phone=user.phone,
|
||||||
|
name=user.name,
|
||||||
|
avatar=user.avatar,
|
||||||
|
role=user.role.value,
|
||||||
|
is_verified=user.is_verified,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
if brand:
|
||||||
|
resp.brand = BrandProfile(
|
||||||
|
id=brand.id,
|
||||||
|
name=brand.name,
|
||||||
|
logo=brand.logo,
|
||||||
|
description=brand.description,
|
||||||
|
contact_name=brand.contact_name,
|
||||||
|
contact_phone=brand.contact_phone,
|
||||||
|
contact_email=brand.contact_email,
|
||||||
|
)
|
||||||
|
if agency:
|
||||||
|
resp.agency = AgencyProfile(
|
||||||
|
id=agency.id,
|
||||||
|
name=agency.name,
|
||||||
|
logo=agency.logo,
|
||||||
|
description=agency.description,
|
||||||
|
contact_name=agency.contact_name,
|
||||||
|
contact_phone=agency.contact_phone,
|
||||||
|
contact_email=agency.contact_email,
|
||||||
|
)
|
||||||
|
if creator:
|
||||||
|
resp.creator = CreatorProfile(
|
||||||
|
id=creator.id,
|
||||||
|
name=creator.name,
|
||||||
|
avatar=creator.avatar,
|
||||||
|
bio=creator.bio,
|
||||||
|
douyin_account=creator.douyin_account,
|
||||||
|
xiaohongshu_account=creator.xiaohongshu_account,
|
||||||
|
bilibili_account=creator.bilibili_account,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_role_entity(db: AsyncSession, user: User):
|
||||||
|
"""根据角色获取对应实体"""
|
||||||
|
if user.role == UserRole.BRAND:
|
||||||
|
result = await db.execute(select(Brand).where(Brand.user_id == user.id))
|
||||||
|
return result.scalar_one_or_none(), None, None
|
||||||
|
elif user.role == UserRole.AGENCY:
|
||||||
|
result = await db.execute(select(Agency).where(Agency.user_id == user.id))
|
||||||
|
return None, result.scalar_one_or_none(), None
|
||||||
|
elif user.role == UserRole.CREATOR:
|
||||||
|
result = await db.execute(select(Creator).where(Creator.user_id == user.id))
|
||||||
|
return None, None, result.scalar_one_or_none()
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ProfileResponse)
|
||||||
|
async def get_profile(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""获取当前用户资料"""
|
||||||
|
brand, agency, creator = await _get_role_entity(db, current_user)
|
||||||
|
return _build_profile_response(current_user, brand, agency, creator)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=ProfileResponse)
|
||||||
|
async def update_profile(
|
||||||
|
request: ProfileUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""更新当前用户资料"""
|
||||||
|
# 更新 User 表通用字段
|
||||||
|
if request.name is not None:
|
||||||
|
current_user.name = request.name
|
||||||
|
if request.avatar is not None:
|
||||||
|
current_user.avatar = request.avatar
|
||||||
|
if request.phone is not None:
|
||||||
|
current_user.phone = request.phone
|
||||||
|
|
||||||
|
# 更新角色表字段
|
||||||
|
brand, agency, creator = await _get_role_entity(db, current_user)
|
||||||
|
|
||||||
|
if current_user.role == UserRole.BRAND and brand:
|
||||||
|
if request.name is not None:
|
||||||
|
brand.name = request.name
|
||||||
|
if request.description is not None:
|
||||||
|
brand.description = request.description
|
||||||
|
if request.contact_name is not None:
|
||||||
|
brand.contact_name = request.contact_name
|
||||||
|
if request.contact_phone is not None:
|
||||||
|
brand.contact_phone = request.contact_phone
|
||||||
|
if request.contact_email is not None:
|
||||||
|
brand.contact_email = request.contact_email
|
||||||
|
|
||||||
|
elif current_user.role == UserRole.AGENCY and agency:
|
||||||
|
if request.name is not None:
|
||||||
|
agency.name = request.name
|
||||||
|
if request.description is not None:
|
||||||
|
agency.description = request.description
|
||||||
|
if request.contact_name is not None:
|
||||||
|
agency.contact_name = request.contact_name
|
||||||
|
if request.contact_phone is not None:
|
||||||
|
agency.contact_phone = request.contact_phone
|
||||||
|
if request.contact_email is not None:
|
||||||
|
agency.contact_email = request.contact_email
|
||||||
|
|
||||||
|
elif current_user.role == UserRole.CREATOR and creator:
|
||||||
|
if request.name is not None:
|
||||||
|
creator.name = request.name
|
||||||
|
if request.avatar is not None:
|
||||||
|
creator.avatar = request.avatar
|
||||||
|
if request.bio is not None:
|
||||||
|
creator.bio = request.bio
|
||||||
|
if request.douyin_account is not None:
|
||||||
|
creator.douyin_account = request.douyin_account
|
||||||
|
if request.xiaohongshu_account is not None:
|
||||||
|
creator.xiaohongshu_account = request.xiaohongshu_account
|
||||||
|
if request.bilibili_account is not None:
|
||||||
|
creator.bilibili_account = request.bilibili_account
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 重新查询返回最新数据
|
||||||
|
brand, agency, creator = await _get_role_entity(db, current_user)
|
||||||
|
return _build_profile_response(current_user, brand, agency, creator)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/password")
|
||||||
|
async def change_password(
|
||||||
|
request: ChangePasswordRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""修改密码"""
|
||||||
|
if not verify_password(request.old_password, current_user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="原密码不正确",
|
||||||
|
)
|
||||||
|
|
||||||
|
current_user.password_hash = hash_password(request.new_password)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"message": "密码修改成功"}
|
||||||
@@ -23,6 +23,7 @@ from app.schemas.project import (
|
|||||||
AgencySummary,
|
AgencySummary,
|
||||||
)
|
)
|
||||||
from app.services.auth import generate_id
|
from app.services.auth import generate_id
|
||||||
|
from app.services.message_service import create_message
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["项目"])
|
router = APIRouter(prefix="/projects", tags=["项目"])
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ async def _project_to_response(project: Project, db: AsyncSession) -> ProjectRes
|
|||||||
id=project.id,
|
id=project.id,
|
||||||
name=project.name,
|
name=project.name,
|
||||||
description=project.description,
|
description=project.description,
|
||||||
|
platform=project.platform,
|
||||||
brand_id=project.brand_id,
|
brand_id=project.brand_id,
|
||||||
brand_name=project.brand.name if project.brand else None,
|
brand_name=project.brand.name if project.brand else None,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
@@ -72,6 +74,7 @@ async def create_project(
|
|||||||
brand_id=brand.id,
|
brand_id=brand.id,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
description=request.description,
|
description=request.description,
|
||||||
|
platform=request.platform,
|
||||||
start_date=request.start_date,
|
start_date=request.start_date,
|
||||||
deadline=request.deadline,
|
deadline=request.deadline,
|
||||||
status="active",
|
status="active",
|
||||||
@@ -79,7 +82,7 @@ async def create_project(
|
|||||||
db.add(project)
|
db.add(project)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# 分配代理商
|
# 分配代理商(直接 INSERT 关联表,避免 async 懒加载问题)
|
||||||
if request.agency_ids:
|
if request.agency_ids:
|
||||||
for agency_id in request.agency_ids:
|
for agency_id in request.agency_ids:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -87,7 +90,12 @@ async def create_project(
|
|||||||
)
|
)
|
||||||
agency = result.scalar_one_or_none()
|
agency = result.scalar_one_or_none()
|
||||||
if agency:
|
if agency:
|
||||||
project.agencies.append(agency)
|
await db.execute(
|
||||||
|
project_agency_association.insert().values(
|
||||||
|
project_id=project.id,
|
||||||
|
agency_id=agency.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
await db.refresh(project)
|
await db.refresh(project)
|
||||||
@@ -100,6 +108,40 @@ async def create_project(
|
|||||||
)
|
)
|
||||||
project = result.scalar_one()
|
project = result.scalar_one()
|
||||||
|
|
||||||
|
# 给品牌方用户发送项目创建成功消息
|
||||||
|
brand_user_result = await db.execute(
|
||||||
|
select(User).where(User.id == brand.user_id)
|
||||||
|
)
|
||||||
|
brand_user = brand_user_result.scalar_one_or_none()
|
||||||
|
if brand_user:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=brand_user.id,
|
||||||
|
type="system_notice",
|
||||||
|
title="项目创建成功",
|
||||||
|
content=f"您的项目「{project.name}」已创建成功",
|
||||||
|
related_project_id=project.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 给被分配的代理商发送新项目通知
|
||||||
|
if project.agencies:
|
||||||
|
for agency in project.agencies:
|
||||||
|
agency_user_result = await db.execute(
|
||||||
|
select(User).where(User.id == agency.user_id)
|
||||||
|
)
|
||||||
|
agency_user = agency_user_result.scalar_one_or_none()
|
||||||
|
if agency_user:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=agency_user.id,
|
||||||
|
type="new_task",
|
||||||
|
title="新项目分配",
|
||||||
|
content=f"品牌方「{brand.name}」将您加入了项目「{project.name}」",
|
||||||
|
related_project_id=project.id,
|
||||||
|
sender_name=brand.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
return await _project_to_response(project, db)
|
return await _project_to_response(project, db)
|
||||||
|
|
||||||
|
|
||||||
@@ -248,6 +290,8 @@ async def update_project(
|
|||||||
project.name = request.name
|
project.name = request.name
|
||||||
if request.description is not None:
|
if request.description is not None:
|
||||||
project.description = request.description
|
project.description = request.description
|
||||||
|
if request.platform is not None:
|
||||||
|
project.platform = request.platform
|
||||||
if request.start_date is not None:
|
if request.start_date is not None:
|
||||||
project.start_date = request.start_date
|
project.start_date = request.start_date
|
||||||
if request.deadline is not None:
|
if request.deadline is not None:
|
||||||
@@ -281,6 +325,7 @@ async def assign_agencies(
|
|||||||
if project.brand_id != brand.id:
|
if project.brand_id != brand.id:
|
||||||
raise HTTPException(status_code=403, detail="无权操作此项目")
|
raise HTTPException(status_code=403, detail="无权操作此项目")
|
||||||
|
|
||||||
|
newly_assigned = []
|
||||||
for agency_id in request.agency_ids:
|
for agency_id in request.agency_ids:
|
||||||
agency_result = await db.execute(
|
agency_result = await db.execute(
|
||||||
select(Agency).where(Agency.id == agency_id)
|
select(Agency).where(Agency.id == agency_id)
|
||||||
@@ -288,10 +333,29 @@ async def assign_agencies(
|
|||||||
agency = agency_result.scalar_one_or_none()
|
agency = agency_result.scalar_one_or_none()
|
||||||
if agency and agency not in project.agencies:
|
if agency and agency not in project.agencies:
|
||||||
project.agencies.append(agency)
|
project.agencies.append(agency)
|
||||||
|
newly_assigned.append(agency)
|
||||||
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(project)
|
await db.refresh(project)
|
||||||
|
|
||||||
|
# 给新分配的代理商发送通知
|
||||||
|
for agency in newly_assigned:
|
||||||
|
agency_user_result = await db.execute(
|
||||||
|
select(User).where(User.id == agency.user_id)
|
||||||
|
)
|
||||||
|
agency_user = agency_user_result.scalar_one_or_none()
|
||||||
|
if agency_user:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=agency_user.id,
|
||||||
|
type="new_task",
|
||||||
|
title="新项目分配",
|
||||||
|
content=f"品牌方「{brand.name}」将您加入了项目「{project.name}」",
|
||||||
|
related_project_id=project.id,
|
||||||
|
sender_name=brand.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
return await _project_to_response(project, db)
|
return await _project_to_response(project, db)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+586
-21
@@ -2,8 +2,10 @@
|
|||||||
规则管理 API
|
规则管理 API
|
||||||
违禁词库、白名单、竞品库、平台规则
|
违禁词库、白名单、竞品库、平台规则
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_
|
||||||
@@ -11,7 +13,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor, PlatformRule, RuleStatus
|
||||||
|
from app.schemas.rules import (
|
||||||
|
PlatformRuleParseRequest,
|
||||||
|
PlatformRuleParseResponse,
|
||||||
|
PlatformRuleConfirmRequest,
|
||||||
|
PlatformRuleResponse as PlatformRuleDBResponse,
|
||||||
|
PlatformRuleListResponse as PlatformRuleDBListResponse,
|
||||||
|
ParsedRulesData,
|
||||||
|
)
|
||||||
|
from app.services.document_parser import DocumentParser
|
||||||
|
from app.services.ai_service import AIServiceFactory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/rules", tags=["rules"])
|
router = APIRouter(prefix="/rules", tags=["rules"])
|
||||||
|
|
||||||
@@ -117,10 +131,14 @@ _platform_rules = {
|
|||||||
"xiaohongshu": {
|
"xiaohongshu": {
|
||||||
"platform": "xiaohongshu",
|
"platform": "xiaohongshu",
|
||||||
"rules": [
|
"rules": [
|
||||||
{"type": "forbidden_word", "words": ["最好", "绝对", "100%"]},
|
{"type": "forbidden_word", "words": [
|
||||||
|
"最好", "绝对", "100%", "第一", "最佳", "国家级", "顶级",
|
||||||
|
"万能", "神器", "秒杀", "碾压", "永久", "根治",
|
||||||
|
"一次见效", "立竿见影", "无副作用",
|
||||||
|
]},
|
||||||
],
|
],
|
||||||
"version": "2024.01",
|
"version": "2024.06",
|
||||||
"updated_at": "2024-01-10T00:00:00Z",
|
"updated_at": "2024-06-15T00:00:00Z",
|
||||||
},
|
},
|
||||||
"bilibili": {
|
"bilibili": {
|
||||||
"platform": "bilibili",
|
"platform": "bilibili",
|
||||||
@@ -322,6 +340,33 @@ async def add_to_whitelist(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/whitelist/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_whitelist_item(
|
||||||
|
item_id: str,
|
||||||
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""删除白名单项"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(WhitelistItem).where(
|
||||||
|
and_(
|
||||||
|
WhitelistItem.id == item_id,
|
||||||
|
WhitelistItem.tenant_id == x_tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"白名单项不存在: {item_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.delete(item)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
# ==================== 竞品库 ====================
|
# ==================== 竞品库 ====================
|
||||||
|
|
||||||
@router.get("/competitors", response_model=CompetitorListResponse)
|
@router.get("/competitors", response_model=CompetitorListResponse)
|
||||||
@@ -441,33 +486,496 @@ async def get_platform_rules(platform: str) -> PlatformRuleResponse:
|
|||||||
# ==================== 规则冲突检测 ====================
|
# ==================== 规则冲突检测 ====================
|
||||||
|
|
||||||
@router.post("/validate", response_model=RuleValidateResponse)
|
@router.post("/validate", response_model=RuleValidateResponse)
|
||||||
async def validate_rules(request: RuleValidateRequest) -> RuleValidateResponse:
|
async def validate_rules(
|
||||||
"""检测 Brief 与平台规则冲突"""
|
request: RuleValidateRequest,
|
||||||
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> RuleValidateResponse:
|
||||||
|
"""检测 Brief 与平台规则冲突(合并 DB 规则 + 硬编码兜底)"""
|
||||||
conflicts = []
|
conflicts = []
|
||||||
|
|
||||||
platform_rule = _platform_rules.get(request.platform)
|
# 1. 收集违禁词:DB active 规则优先,硬编码兜底
|
||||||
if not platform_rule:
|
db_rules = await get_active_platform_rules(
|
||||||
return RuleValidateResponse(conflicts=[])
|
x_tenant_id, request.brand_id, request.platform, db
|
||||||
|
)
|
||||||
|
forbidden_words: set[str] = set()
|
||||||
|
min_seconds: Optional[int] = None
|
||||||
|
max_seconds: Optional[int] = None
|
||||||
|
|
||||||
# 检查 required_phrases 是否包含违禁词
|
if db_rules:
|
||||||
required_phrases = request.brief_rules.get("required_phrases", [])
|
forbidden_words.update(db_rules.get("forbidden_words", []))
|
||||||
platform_forbidden = []
|
duration = db_rules.get("duration") or {}
|
||||||
for rule in platform_rule.get("rules", []):
|
min_seconds = duration.get("min_seconds")
|
||||||
|
max_seconds = duration.get("max_seconds")
|
||||||
|
|
||||||
|
# 硬编码兜底
|
||||||
|
hardcoded = _platform_rules.get(request.platform, {})
|
||||||
|
for rule in hardcoded.get("rules", []):
|
||||||
if rule.get("type") == "forbidden_word":
|
if rule.get("type") == "forbidden_word":
|
||||||
platform_forbidden.extend(rule.get("words", []))
|
forbidden_words.update(rule.get("words", []))
|
||||||
|
elif rule.get("type") == "duration" and min_seconds is None:
|
||||||
|
if rule.get("min_seconds") is not None:
|
||||||
|
min_seconds = rule["min_seconds"]
|
||||||
|
if rule.get("max_seconds") is not None and max_seconds is None:
|
||||||
|
max_seconds = rule["max_seconds"]
|
||||||
|
|
||||||
for phrase in required_phrases:
|
# 2. 检查卖点/必选短语与违禁词冲突
|
||||||
for word in platform_forbidden:
|
phrases = list(request.brief_rules.get("required_phrases", []))
|
||||||
if word in phrase:
|
phrases += list(request.brief_rules.get("selling_points", []))
|
||||||
|
for phrase in phrases:
|
||||||
|
for word in forbidden_words:
|
||||||
|
if word in str(phrase):
|
||||||
conflicts.append(RuleConflict(
|
conflicts.append(RuleConflict(
|
||||||
brief_rule=f"要求使用:{phrase}",
|
brief_rule=f"卖点包含:{phrase}",
|
||||||
platform_rule=f"平台禁止:{word}",
|
platform_rule=f"{request.platform} 禁止使用:{word}",
|
||||||
suggestion=f"Brief 要求的 '{phrase}' 包含平台违禁词 '{word}',建议修改",
|
suggestion=f"卖点 '{phrase}' 包含违禁词 '{word}',建议修改表述",
|
||||||
|
))
|
||||||
|
|
||||||
|
# 3. 检查时长冲突
|
||||||
|
brief_min = request.brief_rules.get("min_duration")
|
||||||
|
brief_max = request.brief_rules.get("max_duration")
|
||||||
|
if min_seconds and brief_max and brief_max < min_seconds:
|
||||||
|
conflicts.append(RuleConflict(
|
||||||
|
brief_rule=f"Brief 最长时长:{brief_max}秒",
|
||||||
|
platform_rule=f"{request.platform} 最短要求:{min_seconds}秒",
|
||||||
|
suggestion=f"Brief 最长 {brief_max}s 低于平台最短要求 {min_seconds}s,视频可能不达标",
|
||||||
|
))
|
||||||
|
if max_seconds and brief_min and brief_min > max_seconds:
|
||||||
|
conflicts.append(RuleConflict(
|
||||||
|
brief_rule=f"Brief 最短时长:{brief_min}秒",
|
||||||
|
platform_rule=f"{request.platform} 最长限制:{max_seconds}秒",
|
||||||
|
suggestion=f"Brief 最短 {brief_min}s 超过平台最长限制 {max_seconds}s,建议调整",
|
||||||
))
|
))
|
||||||
|
|
||||||
return RuleValidateResponse(conflicts=conflicts)
|
return RuleValidateResponse(conflicts=conflicts)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 品牌方平台规则(文档上传 + AI 解析) ====================
|
||||||
|
|
||||||
|
def _format_platform_rule(rule: PlatformRule) -> PlatformRuleDBResponse:
|
||||||
|
"""将 ORM 对象转为响应 Schema"""
|
||||||
|
return PlatformRuleDBResponse(
|
||||||
|
id=rule.id,
|
||||||
|
platform=rule.platform,
|
||||||
|
brand_id=rule.brand_id,
|
||||||
|
document_url=rule.document_url,
|
||||||
|
document_name=rule.document_name,
|
||||||
|
parsed_rules=ParsedRulesData(**(rule.parsed_rules or {})),
|
||||||
|
status=rule.status,
|
||||||
|
created_at=rule.created_at.isoformat() if rule.created_at else "",
|
||||||
|
updated_at=rule.updated_at.isoformat() if rule.updated_at else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/platform-rules/parse",
|
||||||
|
response_model=PlatformRuleParseResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def parse_platform_rule_document(
|
||||||
|
request: PlatformRuleParseRequest,
|
||||||
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> PlatformRuleParseResponse:
|
||||||
|
"""
|
||||||
|
上传文档并通过 AI 解析平台规则
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 下载文档
|
||||||
|
2. 提取纯文本
|
||||||
|
3. AI 解析出结构化规则
|
||||||
|
4. 存入 DB (status=draft)
|
||||||
|
5. 返回解析结果供品牌方确认
|
||||||
|
"""
|
||||||
|
await _ensure_tenant_exists(x_tenant_id, db)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
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}")
|
||||||
|
|
||||||
|
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]}"
|
||||||
|
rule = PlatformRule(
|
||||||
|
id=rule_id,
|
||||||
|
tenant_id=x_tenant_id,
|
||||||
|
brand_id=request.brand_id,
|
||||||
|
platform=request.platform,
|
||||||
|
document_url=request.document_url,
|
||||||
|
document_name=request.document_name,
|
||||||
|
parsed_rules=parsed_rules,
|
||||||
|
status=RuleStatus.DRAFT.value,
|
||||||
|
)
|
||||||
|
db.add(rule)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return PlatformRuleParseResponse(
|
||||||
|
id=rule.id,
|
||||||
|
platform=rule.platform,
|
||||||
|
brand_id=rule.brand_id,
|
||||||
|
document_url=rule.document_url,
|
||||||
|
document_name=rule.document_name,
|
||||||
|
parsed_rules=ParsedRulesData(**parsed_rules),
|
||||||
|
status=rule.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/platform-rules/{rule_id}/confirm",
|
||||||
|
response_model=PlatformRuleDBResponse,
|
||||||
|
)
|
||||||
|
async def confirm_platform_rule(
|
||||||
|
rule_id: str,
|
||||||
|
request: PlatformRuleConfirmRequest,
|
||||||
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> PlatformRuleDBResponse:
|
||||||
|
"""
|
||||||
|
确认/编辑平台规则解析结果
|
||||||
|
|
||||||
|
将 draft 状态的规则设为 active,同时将同 (tenant_id, brand_id, platform) 下
|
||||||
|
已有的 active 规则设为 inactive。
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlatformRule).where(
|
||||||
|
and_(
|
||||||
|
PlatformRule.id == rule_id,
|
||||||
|
PlatformRule.tenant_id == x_tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(status_code=404, detail=f"规则不存在: {rule_id}")
|
||||||
|
|
||||||
|
# 将同 (tenant_id, brand_id, platform) 下已有的 active 规则设为 inactive
|
||||||
|
existing_active = await db.execute(
|
||||||
|
select(PlatformRule).where(
|
||||||
|
and_(
|
||||||
|
PlatformRule.tenant_id == x_tenant_id,
|
||||||
|
PlatformRule.brand_id == rule.brand_id,
|
||||||
|
PlatformRule.platform == rule.platform,
|
||||||
|
PlatformRule.status == RuleStatus.ACTIVE.value,
|
||||||
|
PlatformRule.id != rule_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for old_rule in existing_active.scalars().all():
|
||||||
|
old_rule.status = RuleStatus.INACTIVE.value
|
||||||
|
|
||||||
|
# 更新当前规则
|
||||||
|
rule.parsed_rules = request.parsed_rules.model_dump()
|
||||||
|
rule.status = RuleStatus.ACTIVE.value
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(rule)
|
||||||
|
|
||||||
|
return _format_platform_rule(rule)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/platform-rules",
|
||||||
|
response_model=PlatformRuleDBListResponse,
|
||||||
|
)
|
||||||
|
async def list_brand_platform_rules(
|
||||||
|
brand_id: Optional[str] = Query(None),
|
||||||
|
platform: Optional[str] = Query(None),
|
||||||
|
rule_status: Optional[str] = Query(None, alias="status"),
|
||||||
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> PlatformRuleDBListResponse:
|
||||||
|
"""查询品牌方的平台规则列表"""
|
||||||
|
query = select(PlatformRule).where(PlatformRule.tenant_id == x_tenant_id)
|
||||||
|
|
||||||
|
if brand_id:
|
||||||
|
query = query.where(PlatformRule.brand_id == brand_id)
|
||||||
|
if platform:
|
||||||
|
query = query.where(PlatformRule.platform == platform)
|
||||||
|
if rule_status:
|
||||||
|
query = query.where(PlatformRule.status == rule_status)
|
||||||
|
|
||||||
|
result = await db.execute(query.order_by(PlatformRule.created_at.desc()))
|
||||||
|
rules = result.scalars().all()
|
||||||
|
|
||||||
|
return PlatformRuleDBListResponse(
|
||||||
|
items=[_format_platform_rule(r) for r in rules],
|
||||||
|
total=len(rules),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/platform-rules/{rule_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
async def delete_platform_rule(
|
||||||
|
rule_id: str,
|
||||||
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""删除平台规则"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlatformRule).where(
|
||||||
|
and_(
|
||||||
|
PlatformRule.id == rule_id,
|
||||||
|
PlatformRule.tenant_id == x_tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(status_code=404, detail=f"规则不存在: {rule_id}")
|
||||||
|
|
||||||
|
await db.delete(rule)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ai_parse_platform_rules(
|
||||||
|
tenant_id: str,
|
||||||
|
platform: str,
|
||||||
|
document_text: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
使用 AI 将文档文本解析为结构化平台规则
|
||||||
|
|
||||||
|
AI 失败时返回空规则结构(降级为手动编辑)
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
text_model = config.models.get("text", "gpt-4o")
|
||||||
|
|
||||||
|
# 截断过长文本(避免超出 token 限制)
|
||||||
|
max_chars = 15000
|
||||||
|
if len(document_text) > max_chars:
|
||||||
|
document_text = document_text[:max_chars] + "\n...(文档内容已截断)"
|
||||||
|
|
||||||
|
prompt = f"""你是平台广告合规规则分析专家。请从以下 {platform} 平台规则文档中提取结构化规则。
|
||||||
|
|
||||||
|
文档内容:
|
||||||
|
{document_text}
|
||||||
|
|
||||||
|
请以 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 字符串值中不要使用中文引号(""),使用单引号或直接省略"""
|
||||||
|
|
||||||
|
response = await ai_client.chat_completion(
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
model=text_model,
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解析 AI 响应
|
||||||
|
content = _extract_json_from_ai_response(response.content)
|
||||||
|
parsed = json.loads(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()
|
||||||
|
|
||||||
|
|
||||||
|
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 {
|
||||||
|
"forbidden_words": [],
|
||||||
|
"restricted_words": [],
|
||||||
|
"duration": None,
|
||||||
|
"content_requirements": [],
|
||||||
|
"other_rules": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ==================== 辅助函数(供其他模块调用) ====================
|
# ==================== 辅助函数(供其他模块调用) ====================
|
||||||
|
|
||||||
async def get_whitelist_for_brand(
|
async def get_whitelist_for_brand(
|
||||||
@@ -533,3 +1041,60 @@ async def get_forbidden_words_for_tenant(
|
|||||||
}
|
}
|
||||||
for w in words
|
for w in words
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_competitors_for_brand(
|
||||||
|
tenant_id: str,
|
||||||
|
brand_id: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
获取品牌方配置的竞品列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[{"name": "竞品名", "keywords": ["关键词1", ...]}]
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Competitor).where(
|
||||||
|
and_(
|
||||||
|
Competitor.tenant_id == tenant_id,
|
||||||
|
Competitor.brand_id == brand_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
competitors = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": c.name,
|
||||||
|
"keywords": c.keywords or [],
|
||||||
|
}
|
||||||
|
for c in competitors
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_platform_rules(
|
||||||
|
tenant_id: str,
|
||||||
|
brand_id: str,
|
||||||
|
platform: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
获取品牌方在该平台的生效规则 (active)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
parsed_rules dict 或 None(没有上传规则时)
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlatformRule).where(
|
||||||
|
and_(
|
||||||
|
PlatformRule.tenant_id == tenant_id,
|
||||||
|
PlatformRule.brand_id == brand_id,
|
||||||
|
PlatformRule.platform == platform,
|
||||||
|
PlatformRule.status == RuleStatus.ACTIVE.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
return None
|
||||||
|
return rule.parsed_rules
|
||||||
|
|||||||
+800
-92
File diff suppressed because it is too large
Load Diff
+738
-3
@@ -2,13 +2,15 @@
|
|||||||
任务 API
|
任务 API
|
||||||
实现完整的审核任务流程
|
实现完整的审核任务流程
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db, AsyncSessionLocal
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.models.task import Task, TaskStage, TaskStatus
|
from app.models.task import Task, TaskStage, TaskStatus
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
@@ -41,6 +43,7 @@ from app.services.task_service import (
|
|||||||
check_task_permission,
|
check_task_permission,
|
||||||
upload_script,
|
upload_script,
|
||||||
upload_video,
|
upload_video,
|
||||||
|
complete_ai_review,
|
||||||
agency_review,
|
agency_review,
|
||||||
brand_review,
|
brand_review,
|
||||||
submit_appeal,
|
submit_appeal,
|
||||||
@@ -51,10 +54,406 @@ from app.services.task_service import (
|
|||||||
list_pending_reviews_for_agency,
|
list_pending_reviews_for_agency,
|
||||||
list_pending_reviews_for_brand,
|
list_pending_reviews_for_brand,
|
||||||
)
|
)
|
||||||
|
from app.api.sse import notify_new_task, notify_task_updated, notify_review_decision
|
||||||
|
from app.services.message_service import create_message
|
||||||
|
from app.models.brief import Brief
|
||||||
|
from app.schemas.review import ScriptReviewRequest, Platform
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/tasks", tags=["任务"])
|
router = APIRouter(prefix="/tasks", tags=["任务"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_script_ai_review(task_id: str, tenant_id: str):
|
||||||
|
"""
|
||||||
|
后台执行脚本 AI 审核
|
||||||
|
|
||||||
|
- 获取 Brief 信息(卖点、黑名单词)
|
||||||
|
- 调用 review_script 进行审核
|
||||||
|
- 保存审核结果并推进任务阶段
|
||||||
|
- 发送 SSE 通知
|
||||||
|
"""
|
||||||
|
from app.api.scripts import review_script
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
task = await get_task_by_id(db, task_id)
|
||||||
|
if not task or task.stage.value != "script_ai_review":
|
||||||
|
logger.warning(f"任务 {task_id} 不在 AI 审核阶段,跳过")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取项目信息
|
||||||
|
project_result = await db.execute(
|
||||||
|
select(Project).where(Project.id == task.project_id)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
logger.error(f"任务 {task_id} 对应的项目不存在")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取 Brief
|
||||||
|
brief_result = await db.execute(
|
||||||
|
select(Brief).where(Brief.project_id == project.id)
|
||||||
|
)
|
||||||
|
brief = brief_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# 构建审核请求
|
||||||
|
platform = project.platform or "douyin"
|
||||||
|
selling_points = brief.selling_points if brief else None
|
||||||
|
blacklist_words = brief.blacklist_words if brief else None
|
||||||
|
min_selling_points = brief.min_selling_points if brief else None
|
||||||
|
|
||||||
|
request = ScriptReviewRequest(
|
||||||
|
content=" ", # 占位,实际内容从 file_url 解析
|
||||||
|
platform=Platform(platform),
|
||||||
|
brand_id=project.brand_id,
|
||||||
|
selling_points=selling_points,
|
||||||
|
min_selling_points=min_selling_points,
|
||||||
|
blacklist_words=blacklist_words,
|
||||||
|
file_url=task.script_file_url,
|
||||||
|
file_name=task.script_file_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用审核逻辑
|
||||||
|
result = await review_script(
|
||||||
|
request=request,
|
||||||
|
x_tenant_id=tenant_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存审核结果
|
||||||
|
task = await get_task_by_id(db, task_id)
|
||||||
|
task = await complete_ai_review(
|
||||||
|
db=db,
|
||||||
|
task=task,
|
||||||
|
review_type="script",
|
||||||
|
score=result.score,
|
||||||
|
result=result.model_dump(),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
ai_auto_rejected = task.script_ai_result and task.script_ai_result.get("ai_auto_rejected")
|
||||||
|
logger.info(f"任务 {task_id} AI 审核完成,得分: {result.score},自动驳回: {ai_auto_rejected}")
|
||||||
|
|
||||||
|
if ai_auto_rejected:
|
||||||
|
# AI 自动驳回:只通知达人
|
||||||
|
try:
|
||||||
|
creator_result = await db.execute(
|
||||||
|
select(Creator).where(Creator.id == task.creator_id)
|
||||||
|
)
|
||||||
|
creator_obj = creator_result.scalar_one_or_none()
|
||||||
|
if creator_obj:
|
||||||
|
reject_reason = task.script_ai_result.get("ai_reject_reason", "")
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[creator_obj.user_id],
|
||||||
|
data={"action": "ai_auto_rejected", "stage": task.stage.value, "score": result.score},
|
||||||
|
)
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=creator_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="脚本未通过 AI 审核",
|
||||||
|
content=f"任务「{task.name}」未通过 AI 审核({result.score} 分),原因:{reject_reason}。请修改后重新上传。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name="系统",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# 正常通过:SSE 通知达人和代理商 + 消息通知代理商
|
||||||
|
try:
|
||||||
|
user_ids = []
|
||||||
|
creator_result = await db.execute(
|
||||||
|
select(Creator).where(Creator.id == task.creator_id)
|
||||||
|
)
|
||||||
|
creator_obj = creator_result.scalar_one_or_none()
|
||||||
|
if creator_obj:
|
||||||
|
user_ids.append(creator_obj.user_id)
|
||||||
|
|
||||||
|
agency_result = await db.execute(
|
||||||
|
select(Agency).where(Agency.id == task.agency_id)
|
||||||
|
)
|
||||||
|
agency_obj = agency_result.scalar_one_or_none()
|
||||||
|
if agency_obj:
|
||||||
|
user_ids.append(agency_obj.user_id)
|
||||||
|
|
||||||
|
if user_ids:
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=user_ids,
|
||||||
|
data={"action": "ai_review_completed", "stage": task.stage.value, "score": result.score},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
ag_result = await db.execute(
|
||||||
|
select(Agency).where(Agency.id == task.agency_id)
|
||||||
|
)
|
||||||
|
ag_obj = ag_result.scalar_one_or_none()
|
||||||
|
if ag_obj:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=ag_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="脚本 AI 审核完成",
|
||||||
|
content=f"任务「{task.name}」AI 审核完成,综合得分 {result.score} 分,请审核。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name="系统",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# AI 未配置时通知品牌方
|
||||||
|
if not result.ai_available:
|
||||||
|
try:
|
||||||
|
brand_result = await db.execute(
|
||||||
|
select(Brand).where(Brand.id == project.brand_id)
|
||||||
|
)
|
||||||
|
brand_obj = brand_result.scalar_one_or_none()
|
||||||
|
if brand_obj and brand_obj.user_id:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=brand_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="AI 审核降级运行",
|
||||||
|
content=f"任务「{task.name}」的 AI 审核已降级运行(仅关键词检测),请前往「AI 配置」完成设置以获得更精准的审核结果。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name="系统",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"任务 {task_id} AI 审核失败: {e}", exc_info=True)
|
||||||
|
await db.rollback()
|
||||||
|
# AI 审核异常时通知品牌方(rollback 后重新开始事务)
|
||||||
|
try:
|
||||||
|
brand_result = await db.execute(
|
||||||
|
select(Brand).where(Brand.id == tenant_id)
|
||||||
|
)
|
||||||
|
brand_obj = brand_result.scalar_one_or_none()
|
||||||
|
if brand_obj and brand_obj.user_id:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=brand_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="AI 审核异常",
|
||||||
|
content=f"任务 AI 审核过程中出错,审核结果可能不完整,请检查 AI 服务配置。错误信息:{str(e)[:100]}",
|
||||||
|
related_task_id=task_id,
|
||||||
|
sender_name="系统",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_video_ai_review(task_id: str, tenant_id: str):
|
||||||
|
"""
|
||||||
|
后台执行视频 AI 审核
|
||||||
|
|
||||||
|
复用脚本审核的完整规则检测链(违禁词/竞品/平台规则/白名单/AI深度分析)。
|
||||||
|
审核内容来源:已通过审核的脚本文本 + 视频文件(如可解析)。
|
||||||
|
"""
|
||||||
|
from app.api.scripts import review_script
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(2) # 模拟处理延迟
|
||||||
|
|
||||||
|
task = await get_task_by_id(db, task_id)
|
||||||
|
if not task or task.stage.value != "video_ai_review":
|
||||||
|
logger.warning(f"任务 {task_id} 不在视频 AI 审核阶段,跳过")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取项目信息
|
||||||
|
project_result = await db.execute(
|
||||||
|
select(Project).where(Project.id == task.project_id)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
logger.error(f"任务 {task_id} 对应的项目不存在")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取 Brief
|
||||||
|
brief_result = await db.execute(
|
||||||
|
select(Brief).where(Brief.project_id == project.id)
|
||||||
|
)
|
||||||
|
brief = brief_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
platform = project.platform or "douyin"
|
||||||
|
selling_points = brief.selling_points if brief else None
|
||||||
|
blacklist_words = brief.blacklist_words if brief else None
|
||||||
|
min_selling_points = brief.min_selling_points if brief else None
|
||||||
|
|
||||||
|
# 使用脚本内容作为审核基础(视频 ASR 尚未实现,先复用脚本文本)
|
||||||
|
script_content = ""
|
||||||
|
if task.script_file_url and task.script_file_name:
|
||||||
|
# 脚本文件可用,复用
|
||||||
|
pass # review_script 会自动解析 file_url
|
||||||
|
|
||||||
|
request = ScriptReviewRequest(
|
||||||
|
content=script_content or " ",
|
||||||
|
platform=Platform(platform),
|
||||||
|
brand_id=project.brand_id,
|
||||||
|
selling_points=selling_points,
|
||||||
|
min_selling_points=min_selling_points,
|
||||||
|
blacklist_words=blacklist_words,
|
||||||
|
file_url=task.script_file_url,
|
||||||
|
file_name=task.script_file_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用完整审核逻辑(竞品/违禁词/平台规则/白名单/AI深度分析全部参与)
|
||||||
|
result = await review_script(
|
||||||
|
request=request,
|
||||||
|
x_tenant_id=tenant_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
video_score = result.score
|
||||||
|
video_result = {
|
||||||
|
"score": video_score,
|
||||||
|
"summary": result.summary,
|
||||||
|
"violations": [v.model_dump() for v in result.violations],
|
||||||
|
"soft_warnings": [w.model_dump() for w in result.soft_warnings],
|
||||||
|
"dimensions": result.dimensions.model_dump(),
|
||||||
|
"selling_point_matches": [sp.model_dump() for sp in result.selling_point_matches],
|
||||||
|
}
|
||||||
|
|
||||||
|
task = await get_task_by_id(db, task_id)
|
||||||
|
task = await complete_ai_review(
|
||||||
|
db=db,
|
||||||
|
task=task,
|
||||||
|
review_type="video",
|
||||||
|
score=video_score,
|
||||||
|
result=video_result,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
ai_auto_rejected = task.video_ai_result and task.video_ai_result.get("ai_auto_rejected")
|
||||||
|
logger.info(f"任务 {task_id} 视频 AI 审核完成,得分: {video_score},自动驳回: {ai_auto_rejected}")
|
||||||
|
|
||||||
|
if ai_auto_rejected:
|
||||||
|
# AI 自动驳回:只通知达人
|
||||||
|
try:
|
||||||
|
creator_result = await db.execute(
|
||||||
|
select(Creator).where(Creator.id == task.creator_id)
|
||||||
|
)
|
||||||
|
creator_obj = creator_result.scalar_one_or_none()
|
||||||
|
if creator_obj:
|
||||||
|
reject_reason = task.video_ai_result.get("ai_reject_reason", "")
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[creator_obj.user_id],
|
||||||
|
data={"action": "ai_auto_rejected", "stage": task.stage.value, "score": video_score},
|
||||||
|
)
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=creator_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="视频未通过 AI 审核",
|
||||||
|
content=f"任务「{task.name}」视频未通过 AI 审核({video_score} 分),原因:{reject_reason}。请修改后重新上传。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name="系统",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# 正常通过:SSE 通知达人和代理商 + 消息通知代理商
|
||||||
|
try:
|
||||||
|
user_ids = []
|
||||||
|
creator_result = await db.execute(
|
||||||
|
select(Creator).where(Creator.id == task.creator_id)
|
||||||
|
)
|
||||||
|
creator_obj = creator_result.scalar_one_or_none()
|
||||||
|
if creator_obj:
|
||||||
|
user_ids.append(creator_obj.user_id)
|
||||||
|
|
||||||
|
agency_result = await db.execute(
|
||||||
|
select(Agency).where(Agency.id == task.agency_id)
|
||||||
|
)
|
||||||
|
agency_obj = agency_result.scalar_one_or_none()
|
||||||
|
if agency_obj:
|
||||||
|
user_ids.append(agency_obj.user_id)
|
||||||
|
|
||||||
|
if user_ids:
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=user_ids,
|
||||||
|
data={"action": "ai_review_completed", "stage": task.stage.value, "score": video_score},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
ag_result = await db.execute(
|
||||||
|
select(Agency).where(Agency.id == task.agency_id)
|
||||||
|
)
|
||||||
|
ag_obj = ag_result.scalar_one_or_none()
|
||||||
|
if ag_obj:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=ag_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="视频 AI 审核完成",
|
||||||
|
content=f"任务「{task.name}」视频 AI 审核完成,得分 {video_score} 分,请审核。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name="系统",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# AI 未配置时通知品牌方
|
||||||
|
if not result.ai_available:
|
||||||
|
try:
|
||||||
|
brand_result = await db.execute(
|
||||||
|
select(Brand).where(Brand.id == project.brand_id)
|
||||||
|
)
|
||||||
|
brand_obj = brand_result.scalar_one_or_none()
|
||||||
|
if brand_obj and brand_obj.user_id:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=brand_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="视频 AI 审核降级运行",
|
||||||
|
content=f"任务「{task.name}」的视频 AI 审核已降级运行(仅关键词检测),请前往「AI 配置」完成设置以获得更精准的审核结果。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name="系统",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"任务 {task_id} 视频 AI 审核失败: {e}", exc_info=True)
|
||||||
|
await db.rollback()
|
||||||
|
# AI 审核异常时通知品牌方(rollback 后重新开始事务)
|
||||||
|
try:
|
||||||
|
brand_result = await db.execute(
|
||||||
|
select(Brand).where(Brand.id == tenant_id)
|
||||||
|
)
|
||||||
|
brand_obj = brand_result.scalar_one_or_none()
|
||||||
|
if brand_obj and brand_obj.user_id:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=brand_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="视频 AI 审核异常",
|
||||||
|
content=f"任务视频 AI 审核过程中出错,审核结果可能不完整,请检查 AI 服务配置。错误信息:{str(e)[:100]}",
|
||||||
|
related_task_id=task_id,
|
||||||
|
sender_name="系统",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _task_to_response(task: Task) -> TaskResponse:
|
def _task_to_response(task: Task) -> TaskResponse:
|
||||||
"""将数据库模型转换为响应模型"""
|
"""将数据库模型转换为响应模型"""
|
||||||
return TaskResponse(
|
return TaskResponse(
|
||||||
@@ -66,6 +465,7 @@ def _task_to_response(task: Task) -> TaskResponse:
|
|||||||
id=task.project.id,
|
id=task.project.id,
|
||||||
name=task.project.name,
|
name=task.project.name,
|
||||||
brand_name=task.project.brand.name if task.project.brand else None,
|
brand_name=task.project.brand.name if task.project.brand else None,
|
||||||
|
platform=task.project.platform,
|
||||||
),
|
),
|
||||||
agency=AgencyInfo(
|
agency=AgencyInfo(
|
||||||
id=task.agency.id,
|
id=task.agency.id,
|
||||||
@@ -172,6 +572,65 @@ async def create_new_task(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
task = await get_task_by_id(db, task.id)
|
||||||
|
|
||||||
|
# 提取通知所需的值(commit 后 ORM 对象会过期,提前缓存)
|
||||||
|
_task_id = task.id
|
||||||
|
_task_name = task.name
|
||||||
|
_project_id = task.project.id
|
||||||
|
_project_name = task.project.name
|
||||||
|
_project_brand_id = task.project.brand_id
|
||||||
|
_agency_name = agency.name
|
||||||
|
_creator_user_id = creator.user_id
|
||||||
|
_creator_name = creator.name or creator.id
|
||||||
|
|
||||||
|
# 创建消息 + SSE 通知达人有新任务
|
||||||
|
try:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=_creator_user_id,
|
||||||
|
type="new_task",
|
||||||
|
title="新任务分配",
|
||||||
|
content=f"您有新的任务「{_task_name}」,来自项目「{_project_name}」",
|
||||||
|
related_task_id=_task_id,
|
||||||
|
related_project_id=_project_id,
|
||||||
|
sender_name=_agency_name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"创建达人通知消息失败: {e}")
|
||||||
|
|
||||||
|
# 通知品牌方:代理商给项目添加了达人
|
||||||
|
try:
|
||||||
|
brand_result = await db.execute(
|
||||||
|
select(Brand).where(Brand.id == _project_brand_id)
|
||||||
|
)
|
||||||
|
brand = brand_result.scalar_one_or_none()
|
||||||
|
if brand and brand.user_id:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=brand.user_id,
|
||||||
|
type="new_task",
|
||||||
|
title="达人加入项目",
|
||||||
|
content=f"代理商「{_agency_name}」将达人「{_creator_name}」加入项目「{_project_name}」,任务:{_task_name}",
|
||||||
|
related_task_id=_task_id,
|
||||||
|
related_project_id=_project_id,
|
||||||
|
sender_name=_agency_name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
else:
|
||||||
|
logger.warning(f"品牌方不存在或无 user_id: brand_id={_project_brand_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"创建品牌方通知消息失败: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await notify_new_task(
|
||||||
|
task_id=_task_id,
|
||||||
|
creator_user_id=_creator_user_id,
|
||||||
|
task_name=_task_name,
|
||||||
|
project_name=_project_name,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"SSE 通知失败: {e}")
|
||||||
|
|
||||||
return _task_to_response(task)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@@ -183,6 +642,7 @@ async def list_tasks(
|
|||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
stage: Optional[TaskStage] = Query(None),
|
stage: Optional[TaskStage] = Query(None),
|
||||||
|
project_id: Optional[str] = Query(None),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -215,7 +675,7 @@ async def list_tasks(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="代理商信息不存在",
|
detail="代理商信息不存在",
|
||||||
)
|
)
|
||||||
tasks, total = await list_tasks_for_agency(db, agency.id, page, page_size, stage)
|
tasks, total = await list_tasks_for_agency(db, agency.id, page, page_size, stage, project_id)
|
||||||
|
|
||||||
elif current_user.role == UserRole.BRAND:
|
elif current_user.role == UserRole.BRAND:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -227,7 +687,7 @@ async def list_tasks(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="品牌方信息不存在",
|
detail="品牌方信息不存在",
|
||||||
)
|
)
|
||||||
tasks, total = await list_tasks_for_brand(db, brand.id, page, page_size, stage)
|
tasks, total = await list_tasks_for_brand(db, brand.id, page, page_size, stage, project_id)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -367,6 +827,43 @@ async def upload_task_script(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
task = await get_task_by_id(db, task.id)
|
||||||
|
|
||||||
|
# 通知代理商脚本已上传(消息 + SSE)
|
||||||
|
try:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Agency).where(Agency.id == task.agency_id)
|
||||||
|
)
|
||||||
|
agency_obj = result.scalar_one_or_none()
|
||||||
|
if agency_obj:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=agency_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="达人已上传脚本",
|
||||||
|
content=f"任务「{task.name}」的脚本已上传,等待 AI 审核。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name=creator.name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[agency_obj.user_id],
|
||||||
|
data={"action": "script_uploaded", "stage": task.stage.value},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 获取 tenant_id (品牌方 ID) 并在后台触发 AI 审核
|
||||||
|
try:
|
||||||
|
project_result = await db.execute(
|
||||||
|
select(Project).where(Project.id == task.project_id)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
asyncio.create_task(_run_script_ai_review(task.id, project.brand_id))
|
||||||
|
logger.info(f"已触发任务 {task.id} 的后台 AI 审核")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"触发 AI 审核失败: {e}")
|
||||||
|
|
||||||
return _task_to_response(task)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@@ -415,6 +912,43 @@ async def upload_task_video(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
task = await get_task_by_id(db, task.id)
|
||||||
|
|
||||||
|
# 通知代理商视频已上传(消息 + SSE)
|
||||||
|
try:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Agency).where(Agency.id == task.agency_id)
|
||||||
|
)
|
||||||
|
agency_obj = result.scalar_one_or_none()
|
||||||
|
if agency_obj:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=agency_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="达人已上传视频",
|
||||||
|
content=f"任务「{task.name}」的视频已上传,等待 AI 审核。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name=creator.name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[agency_obj.user_id],
|
||||||
|
data={"action": "video_uploaded", "stage": task.stage.value},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 获取 tenant_id 并在后台触发视频 AI 审核
|
||||||
|
try:
|
||||||
|
project_result = await db.execute(
|
||||||
|
select(Project).where(Project.id == task.project_id)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if project:
|
||||||
|
asyncio.create_task(_run_video_ai_review(task.id, project.brand_id))
|
||||||
|
logger.info(f"已触发任务 {task.id} 的后台视频 AI 审核")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"触发视频 AI 审核失败: {e}")
|
||||||
|
|
||||||
return _task_to_response(task)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@@ -523,6 +1057,94 @@ async def review_script(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
task = await get_task_by_id(db, task.id)
|
||||||
|
|
||||||
|
# 创建消息 + SSE 通知达人脚本审核结果
|
||||||
|
try:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Creator).where(Creator.id == task.creator_id)
|
||||||
|
)
|
||||||
|
creator_obj = result.scalar_one_or_none()
|
||||||
|
if creator_obj:
|
||||||
|
reviewer_type = "agency" if current_user.role == UserRole.AGENCY else "brand"
|
||||||
|
action_text = {"pass": "通过", "reject": "驳回", "force_pass": "强制通过"}.get(request.action, request.action)
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=creator_obj.user_id,
|
||||||
|
type=request.action,
|
||||||
|
title=f"脚本审核{action_text}",
|
||||||
|
content=f"您的任务「{task.name}」脚本已被{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name=current_user.name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await notify_review_decision(
|
||||||
|
task_id=task.id,
|
||||||
|
creator_user_id=creator_obj.user_id,
|
||||||
|
review_type="script",
|
||||||
|
reviewer_type=reviewer_type,
|
||||||
|
action=request.action,
|
||||||
|
comment=request.comment,
|
||||||
|
)
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[creator_obj.user_id],
|
||||||
|
data={"action": f"script_{request.action}", "stage": task.stage.value},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 代理商通过 → 通知品牌方有新内容待审核
|
||||||
|
try:
|
||||||
|
if current_user.role == UserRole.AGENCY and request.action in ("pass", "force_pass"):
|
||||||
|
brand_result = await db.execute(
|
||||||
|
select(Brand).where(Brand.id == task.project.brand_id)
|
||||||
|
)
|
||||||
|
brand_obj = brand_result.scalar_one_or_none()
|
||||||
|
if brand_obj:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=brand_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="新脚本待审核",
|
||||||
|
content=f"任务「{task.name}」脚本已通过代理商审核,请进行品牌终审。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name=current_user.name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[brand_obj.user_id],
|
||||||
|
data={"action": "script_pending_brand_review", "stage": task.stage.value},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 品牌方审核 → 通知代理商结果
|
||||||
|
try:
|
||||||
|
if current_user.role == UserRole.BRAND:
|
||||||
|
ag_result = await db.execute(
|
||||||
|
select(Agency).where(Agency.id == task.agency_id)
|
||||||
|
)
|
||||||
|
ag_obj = ag_result.scalar_one_or_none()
|
||||||
|
if ag_obj:
|
||||||
|
action_text = {"pass": "通过", "reject": "驳回"}.get(request.action, request.action)
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=ag_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title=f"脚本品牌终审{action_text}",
|
||||||
|
content=f"任务「{task.name}」脚本品牌终审已{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name=current_user.name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[ag_obj.user_id],
|
||||||
|
data={"action": f"script_brand_{request.action}", "stage": task.stage.value},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return _task_to_response(task)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@@ -628,6 +1250,94 @@ async def review_video(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
task = await get_task_by_id(db, task.id)
|
||||||
|
|
||||||
|
# 创建消息 + SSE 通知达人视频审核结果
|
||||||
|
try:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Creator).where(Creator.id == task.creator_id)
|
||||||
|
)
|
||||||
|
creator_obj = result.scalar_one_or_none()
|
||||||
|
if creator_obj:
|
||||||
|
reviewer_type = "agency" if current_user.role == UserRole.AGENCY else "brand"
|
||||||
|
action_text = {"pass": "通过", "reject": "驳回", "force_pass": "强制通过"}.get(request.action, request.action)
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=creator_obj.user_id,
|
||||||
|
type=request.action,
|
||||||
|
title=f"视频审核{action_text}",
|
||||||
|
content=f"您的任务「{task.name}」视频已被{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name=current_user.name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await notify_review_decision(
|
||||||
|
task_id=task.id,
|
||||||
|
creator_user_id=creator_obj.user_id,
|
||||||
|
review_type="video",
|
||||||
|
reviewer_type=reviewer_type,
|
||||||
|
action=request.action,
|
||||||
|
comment=request.comment,
|
||||||
|
)
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[creator_obj.user_id],
|
||||||
|
data={"action": f"video_{request.action}", "stage": task.stage.value},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 代理商通过 → 通知品牌方有视频待审核
|
||||||
|
try:
|
||||||
|
if current_user.role == UserRole.AGENCY and request.action in ("pass", "force_pass"):
|
||||||
|
brand_result = await db.execute(
|
||||||
|
select(Brand).where(Brand.id == task.project.brand_id)
|
||||||
|
)
|
||||||
|
brand_obj = brand_result.scalar_one_or_none()
|
||||||
|
if brand_obj:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=brand_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="新视频待审核",
|
||||||
|
content=f"任务「{task.name}」视频已通过代理商审核,请进行品牌终审。",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name=current_user.name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[brand_obj.user_id],
|
||||||
|
data={"action": "video_pending_brand_review", "stage": task.stage.value},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 品牌方审核 → 通知代理商结果
|
||||||
|
try:
|
||||||
|
if current_user.role == UserRole.BRAND:
|
||||||
|
ag_result = await db.execute(
|
||||||
|
select(Agency).where(Agency.id == task.agency_id)
|
||||||
|
)
|
||||||
|
ag_obj = ag_result.scalar_one_or_none()
|
||||||
|
if ag_obj:
|
||||||
|
action_text = {"pass": "通过", "reject": "驳回"}.get(request.action, request.action)
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=ag_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title=f"视频品牌终审{action_text}",
|
||||||
|
content=f"任务「{task.name}」视频品牌终审已{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name=current_user.name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[ag_obj.user_id],
|
||||||
|
data={"action": f"video_brand_{request.action}", "stage": task.stage.value},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return _task_to_response(task)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@@ -676,6 +1386,31 @@ async def submit_task_appeal(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
task = await get_task_by_id(db, task.id)
|
||||||
|
|
||||||
|
# 通知代理商有新申诉(消息 + SSE)
|
||||||
|
try:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Agency).where(Agency.id == task.agency_id)
|
||||||
|
)
|
||||||
|
agency_obj = result.scalar_one_or_none()
|
||||||
|
if agency_obj:
|
||||||
|
await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=agency_obj.user_id,
|
||||||
|
type="task",
|
||||||
|
title="达人提交申诉",
|
||||||
|
content=f"任务「{task.name}」的达人提交了申诉:{request.reason}",
|
||||||
|
related_task_id=task.id,
|
||||||
|
sender_name=creator.name,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await notify_task_updated(
|
||||||
|
task_id=task.id,
|
||||||
|
user_ids=[agency_obj.user_id],
|
||||||
|
data={"action": "appeal_submitted", "stage": task.stage.value},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return _task_to_response(task)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+235
-9
@@ -1,13 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
文件上传 API
|
文件上传 API
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from urllib.parse import quote
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.services.oss import generate_upload_policy, get_file_url
|
from app.services.oss import generate_upload_policy, get_file_url, generate_presigned_url
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/upload", tags=["文件上传"])
|
router = APIRouter(prefix="/upload", tags=["文件上传"])
|
||||||
|
|
||||||
@@ -19,10 +22,12 @@ class UploadPolicyRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UploadPolicyResponse(BaseModel):
|
class UploadPolicyResponse(BaseModel):
|
||||||
"""上传凭证响应"""
|
"""TOS 直传凭证响应"""
|
||||||
access_key_id: str
|
x_tos_algorithm: str
|
||||||
|
x_tos_credential: str
|
||||||
|
x_tos_date: str
|
||||||
|
x_tos_signature: str
|
||||||
policy: str
|
policy: str
|
||||||
signature: str
|
|
||||||
host: str
|
host: str
|
||||||
dir: str
|
dir: str
|
||||||
expire: int
|
expire: int
|
||||||
@@ -49,11 +54,12 @@ class FileUploadedResponse(BaseModel):
|
|||||||
@router.post("/policy", response_model=UploadPolicyResponse)
|
@router.post("/policy", response_model=UploadPolicyResponse)
|
||||||
async def get_upload_policy(
|
async def get_upload_policy(
|
||||||
request: UploadPolicyRequest,
|
request: UploadPolicyRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取 OSS 直传凭证
|
获取 TOS 直传凭证
|
||||||
|
|
||||||
前端使用此凭证直接上传文件到阿里云 OSS,无需经过后端。
|
前端使用此凭证直接上传文件到火山引擎 TOS,无需经过后端。
|
||||||
|
|
||||||
文件类型说明:
|
文件类型说明:
|
||||||
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
|
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
|
||||||
@@ -87,9 +93,11 @@ async def get_upload_policy(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return UploadPolicyResponse(
|
return UploadPolicyResponse(
|
||||||
access_key_id=policy["accessKeyId"],
|
x_tos_algorithm=policy["x_tos_algorithm"],
|
||||||
|
x_tos_credential=policy["x_tos_credential"],
|
||||||
|
x_tos_date=policy["x_tos_date"],
|
||||||
|
x_tos_signature=policy["x_tos_signature"],
|
||||||
policy=policy["policy"],
|
policy=policy["policy"],
|
||||||
signature=policy["signature"],
|
|
||||||
host=policy["host"],
|
host=policy["host"],
|
||||||
dir=policy["dir"],
|
dir=policy["dir"],
|
||||||
expire=policy["expire"],
|
expire=policy["expire"],
|
||||||
@@ -100,6 +108,7 @@ async def get_upload_policy(
|
|||||||
@router.post("/complete", response_model=FileUploadedResponse)
|
@router.post("/complete", response_model=FileUploadedResponse)
|
||||||
async def file_uploaded(
|
async def file_uploaded(
|
||||||
request: FileUploadedRequest,
|
request: FileUploadedRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
文件上传完成回调
|
文件上传完成回调
|
||||||
@@ -115,3 +124,220 @@ async def file_uploaded(
|
|||||||
file_size=request.file_size,
|
file_size=request.file_size,
|
||||||
file_type=request.file_type,
|
file_type=request.file_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SignedUrlResponse(BaseModel):
|
||||||
|
"""签名 URL 响应"""
|
||||||
|
signed_url: str
|
||||||
|
expire_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sign-url", response_model=SignedUrlResponse)
|
||||||
|
async def get_signed_url(
|
||||||
|
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||||
|
expire: int = Query(3600, ge=60, le=43200, description="有效期(秒),默认1小时,最长12小时"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取私有桶文件的预签名访问 URL
|
||||||
|
|
||||||
|
前端在展示/下载文件前调用此接口,获取带签名的临时访问链接。
|
||||||
|
支持传入完整 URL 或 file_key。
|
||||||
|
"""
|
||||||
|
from app.services.oss import parse_file_key_from_url
|
||||||
|
|
||||||
|
# 如果传入的是完整 URL,先解析出 file_key
|
||||||
|
file_key = url
|
||||||
|
if url.startswith("http"):
|
||||||
|
file_key = parse_file_key_from_url(url)
|
||||||
|
|
||||||
|
if not file_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="无效的文件路径",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
signed_url = generate_presigned_url(file_key, expire_seconds=expire)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
return SignedUrlResponse(
|
||||||
|
signed_url=signed_url,
|
||||||
|
expire_seconds=expire,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tos_object(file_key: str) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
从 TOS 获取文件内容和文件名(内部工具函数)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(content, filename)
|
||||||
|
"""
|
||||||
|
import tos as tos_sdk
|
||||||
|
|
||||||
|
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)
|
||||||
|
content = resp.read()
|
||||||
|
|
||||||
|
# 从 file_key 提取文件名,去掉时间戳前缀
|
||||||
|
filename = file_key.split("/")[-1]
|
||||||
|
if "_" in filename and filename.split("_")[0].isdigit():
|
||||||
|
filename = filename.split("_", 1)[1]
|
||||||
|
|
||||||
|
return content, filename
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_file_key(url: str) -> str:
|
||||||
|
"""从 URL 或 file_key 解析出实际 file_key"""
|
||||||
|
from app.services.oss import parse_file_key_from_url
|
||||||
|
|
||||||
|
file_key = url
|
||||||
|
if url.startswith("http"):
|
||||||
|
file_key = parse_file_key_from_url(url)
|
||||||
|
return file_key
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_content_type(filename: str) -> str:
|
||||||
|
"""根据文件名猜测 MIME 类型"""
|
||||||
|
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||||
|
mime_map = {
|
||||||
|
"pdf": "application/pdf",
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"png": "image/png",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"webp": "image/webp",
|
||||||
|
"mp4": "video/mp4",
|
||||||
|
"mov": "video/quicktime",
|
||||||
|
"webm": "video/webm",
|
||||||
|
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"txt": "text/plain",
|
||||||
|
}
|
||||||
|
return mime_map.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/download")
|
||||||
|
async def download_file(
|
||||||
|
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
代理下载文件 — 后端获取 TOS 文件后返回给前端,
|
||||||
|
设置 Content-Disposition: attachment 确保浏览器触发下载。
|
||||||
|
"""
|
||||||
|
file_key = _resolve_file_key(url)
|
||||||
|
if not file_key:
|
||||||
|
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content, filename = _get_tos_object(file_key)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"下载文件失败: {e}")
|
||||||
|
|
||||||
|
from fastapi.responses import Response
|
||||||
|
encoded_filename = quote(filename)
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preview")
|
||||||
|
async def preview_file(
|
||||||
|
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
代理预览文件 — 后端获取 TOS 文件后返回给前端,
|
||||||
|
设置正确的 Content-Type 让浏览器可以直接渲染(PDF / 图片等)。
|
||||||
|
"""
|
||||||
|
file_key = _resolve_file_key(url)
|
||||||
|
if not file_key:
|
||||||
|
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content, filename = _get_tos_object(file_key)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"获取文件失败: {e}")
|
||||||
|
|
||||||
|
from fastapi.responses import Response
|
||||||
|
content_type = _guess_content_type(filename)
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ class Settings(BaseSettings):
|
|||||||
AI_API_KEY: str = "" # 中转服务商的 API Key
|
AI_API_KEY: str = "" # 中转服务商的 API Key
|
||||||
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL,如 https://api.oneinall.ai/v1
|
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL,如 https://api.oneinall.ai/v1
|
||||||
|
|
||||||
# 阿里云 OSS 配置
|
# 火山引擎 TOS 配置
|
||||||
OSS_ACCESS_KEY_ID: str = ""
|
TOS_ACCESS_KEY_ID: str = ""
|
||||||
OSS_ACCESS_KEY_SECRET: str = ""
|
TOS_SECRET_ACCESS_KEY: str = ""
|
||||||
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
|
TOS_REGION: str = "cn-beijing"
|
||||||
OSS_BUCKET_NAME: str = "miaosi-files"
|
TOS_BUCKET_NAME: str = "miaosi-files"
|
||||||
OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com
|
TOS_ENDPOINT: str = "" # 自定义 Endpoint,空则用默认 tos-cn-{region}.volces.com
|
||||||
|
TOS_CDN_DOMAIN: str = "" # CDN 自定义域名,空则用 TOS 源站
|
||||||
|
|
||||||
# 邮件 SMTP
|
# 邮件 SMTP
|
||||||
SMTP_HOST: str = ""
|
SMTP_HOST: str = ""
|
||||||
|
|||||||
+6
-3
@@ -5,7 +5,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.logging_config import setup_logging
|
from app.logging_config import setup_logging
|
||||||
from app.middleware.rate_limit import RateLimitMiddleware
|
from app.middleware.rate_limit import RateLimitMiddleware
|
||||||
from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, sse, projects, briefs, organizations, dashboard, export
|
from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, sse, projects, briefs, organizations, dashboard, export, profile, messages
|
||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -54,8 +54,9 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting (仅生产环境启用)
|
||||||
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
|
if _is_production:
|
||||||
|
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60)
|
||||||
|
|
||||||
# 注册路由
|
# 注册路由
|
||||||
app.include_router(health.router, prefix="/api/v1")
|
app.include_router(health.router, prefix="/api/v1")
|
||||||
@@ -72,6 +73,8 @@ app.include_router(briefs.router, prefix="/api/v1")
|
|||||||
app.include_router(organizations.router, prefix="/api/v1")
|
app.include_router(organizations.router, prefix="/api/v1")
|
||||||
app.include_router(dashboard.router, prefix="/api/v1")
|
app.include_router(dashboard.router, prefix="/api/v1")
|
||||||
app.include_router(export.router, prefix="/api/v1")
|
app.include_router(export.router, prefix="/api/v1")
|
||||||
|
app.include_router(profile.router, prefix="/api/v1")
|
||||||
|
app.include_router(messages.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
简单的速率限制中间件
|
速率限制中间件
|
||||||
基于内存的滑动窗口计数器
|
基于内存的滑动窗口计数器,支持按路径自定义限制和标准响应头。
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -14,50 +14,111 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
速率限制中间件
|
速率限制中间件
|
||||||
|
|
||||||
- 默认: 60 次/分钟 per IP
|
- 默认: 60 次/分钟 per IP
|
||||||
- 登录/注册: 10 次/分钟 per IP
|
- 按路径配置不同限制 (path_limits)
|
||||||
|
- 返回标准 X-RateLimit-* 响应头
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app, default_limit: int = 60, window_seconds: int = 60):
|
# Path-specific rate limits (requests per window).
|
||||||
|
# Paths not listed here fall back to ``default_limit``.
|
||||||
|
DEFAULT_PATH_LIMITS: dict[str, int] = {
|
||||||
|
# Auth endpoints — prevent brute-force / abuse
|
||||||
|
"/api/v1/auth/login": 10,
|
||||||
|
"/api/v1/auth/register": 10,
|
||||||
|
"/api/v1/auth/send-code": 5,
|
||||||
|
"/api/v1/auth/reset-password": 5,
|
||||||
|
# Upload — bandwidth / storage cost
|
||||||
|
"/api/v1/upload/policy": 30,
|
||||||
|
# AI review — service cost + compute
|
||||||
|
"/api/v1/scripts/review": 10,
|
||||||
|
"/api/v1/videos/review": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app,
|
||||||
|
default_limit: int = 60,
|
||||||
|
window_seconds: int = 60,
|
||||||
|
path_limits: dict[str, int] | None = None,
|
||||||
|
):
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
self.default_limit = default_limit
|
self.default_limit = default_limit
|
||||||
self.window_seconds = window_seconds
|
self.window_seconds = window_seconds
|
||||||
self.requests: dict[str, list[float]] = defaultdict(list)
|
self.requests: dict[str, list[float]] = defaultdict(list)
|
||||||
# Stricter limits for auth endpoints
|
# Merge caller-supplied overrides on top of the built-in defaults.
|
||||||
self.strict_paths = {"/api/v1/auth/login", "/api/v1/auth/register"}
|
self.path_limits: dict[str, int] = {**self.DEFAULT_PATH_LIMITS}
|
||||||
self.strict_limit = 10
|
if path_limits:
|
||||||
|
self.path_limits.update(path_limits)
|
||||||
|
|
||||||
|
def _get_limit(self, path: str) -> int:
|
||||||
|
"""Return the rate limit for *path*, falling back to *default_limit*."""
|
||||||
|
return self.path_limits.get(path, self.default_limit)
|
||||||
|
|
||||||
|
def _make_key(self, client_ip: str, path: str) -> str:
|
||||||
|
"""Build the bucket key.
|
||||||
|
|
||||||
|
Paths with a custom limit are bucketed per-IP per-path so that
|
||||||
|
hitting one endpoint does not consume the quota of another.
|
||||||
|
Default paths share a single per-IP bucket.
|
||||||
|
"""
|
||||||
|
if path in self.path_limits:
|
||||||
|
return f"{client_ip}:{path}"
|
||||||
|
return client_ip
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# Determine rate limit
|
limit = self._get_limit(path)
|
||||||
if path in self.strict_paths:
|
key = self._make_key(client_ip, path)
|
||||||
key = f"{client_ip}:{path}"
|
|
||||||
limit = self.strict_limit
|
|
||||||
else:
|
|
||||||
key = client_ip
|
|
||||||
limit = self.default_limit
|
|
||||||
|
|
||||||
# Clean old entries
|
# Clean old entries outside the sliding window
|
||||||
window_start = now - self.window_seconds
|
window_start = now - self.window_seconds
|
||||||
self.requests[key] = [t for t in self.requests[key] if t > window_start]
|
self.requests[key] = [t for t in self.requests[key] if t > window_start]
|
||||||
|
|
||||||
|
current_count = len(self.requests[key])
|
||||||
|
remaining = max(0, limit - current_count)
|
||||||
|
|
||||||
|
# Seconds until the oldest request in the window expires
|
||||||
|
if self.requests[key]:
|
||||||
|
reset_seconds = int(self.requests[key][0] - window_start)
|
||||||
|
else:
|
||||||
|
reset_seconds = self.window_seconds
|
||||||
|
|
||||||
|
# Build common rate-limit headers
|
||||||
|
rate_headers = {
|
||||||
|
"X-RateLimit-Limit": str(limit),
|
||||||
|
"X-RateLimit-Remaining": str(max(0, remaining - 1) if remaining > 0 else 0),
|
||||||
|
"X-RateLimit-Reset": str(reset_seconds),
|
||||||
|
}
|
||||||
|
|
||||||
# Check limit
|
# Check limit
|
||||||
if len(self.requests[key]) >= limit:
|
if current_count >= limit:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
content={"detail": "请求过于频繁,请稍后再试"},
|
content={"detail": "请求过于频繁,请稍后再试"},
|
||||||
|
headers={
|
||||||
|
"X-RateLimit-Limit": str(limit),
|
||||||
|
"X-RateLimit-Remaining": "0",
|
||||||
|
"X-RateLimit-Reset": str(reset_seconds),
|
||||||
|
"Retry-After": str(reset_seconds),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Record request
|
# Record request
|
||||||
self.requests[key].append(now)
|
self.requests[key].append(now)
|
||||||
|
|
||||||
# Periodic cleanup (every 1000 requests to this key)
|
# Periodic cleanup (keep memory bounded)
|
||||||
if len(self.requests) > 10000:
|
if len(self.requests) > 10000:
|
||||||
self._cleanup(now)
|
self._cleanup(now)
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# Attach rate-limit headers to successful responses
|
||||||
|
response.headers["X-RateLimit-Limit"] = rate_headers["X-RateLimit-Limit"]
|
||||||
|
response.headers["X-RateLimit-Remaining"] = rate_headers["X-RateLimit-Remaining"]
|
||||||
|
response.headers["X-RateLimit-Reset"] = rate_headers["X-RateLimit-Reset"]
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _cleanup(self, now: float):
|
def _cleanup(self, now: float):
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from app.models.task import Task, TaskStage, TaskStatus
|
|||||||
from app.models.brief import Brief
|
from app.models.brief import Brief
|
||||||
from app.models.ai_config import AIConfig
|
from app.models.ai_config import AIConfig
|
||||||
from app.models.review import ReviewTask, Platform
|
from app.models.review import ReviewTask, Platform
|
||||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor, PlatformRule, RuleStatus
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.message import Message
|
||||||
# 保留 Tenant 兼容旧代码,但新代码应使用 Brand
|
# 保留 Tenant 兼容旧代码,但新代码应使用 Brand
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
@@ -43,8 +44,12 @@ __all__ = [
|
|||||||
"ForbiddenWord",
|
"ForbiddenWord",
|
||||||
"WhitelistItem",
|
"WhitelistItem",
|
||||||
"Competitor",
|
"Competitor",
|
||||||
|
"PlatformRule",
|
||||||
|
"RuleStatus",
|
||||||
# 审计日志
|
# 审计日志
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
# 消息
|
||||||
|
"Message",
|
||||||
# 兼容
|
# 兼容
|
||||||
"Tenant",
|
"Tenant",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ class Brief(Base, TimestampMixin):
|
|||||||
file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
# 解析后的结构化内容
|
# 解析后的结构化内容
|
||||||
# 卖点要求: [{"content": "SPF50+", "required": true}, ...]
|
# 卖点要求: [{"content": "SPF50+", "priority": "core"}, ...]
|
||||||
selling_points: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
selling_points: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||||
|
|
||||||
|
# 代理商要求至少体现的卖点条数(0 或 None 表示不限制)
|
||||||
|
min_selling_points: Mapped[Optional[int]] = mapped_column(nullable=True)
|
||||||
|
|
||||||
# 违禁词: [{"word": "最好", "reason": "绝对化用语"}, ...]
|
# 违禁词: [{"word": "最好", "reason": "绝对化用语"}, ...]
|
||||||
blacklist_words: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
blacklist_words: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||||
|
|
||||||
@@ -49,10 +52,14 @@ class Brief(Base, TimestampMixin):
|
|||||||
# 其他要求(自由文本)
|
# 其他要求(自由文本)
|
||||||
other_requirements: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
other_requirements: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
# 附件文档(代理商上传的参考资料)
|
# 附件文档(品牌方上传的参考资料)
|
||||||
# [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...]
|
# [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...]
|
||||||
attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||||
|
|
||||||
|
# 代理商附件(代理商上传的补充资料,与品牌方 attachments 分开存储)
|
||||||
|
# [{"id": "af1", "name": "达人拍摄指南.pdf", "url": "...", "size": "1.5MB"}, ...]
|
||||||
|
agency_attachments: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||||
|
|
||||||
# 关联
|
# 关联
|
||||||
project: Mapped["Project"] = relationship("Project", back_populates="brief")
|
project: Mapped["Project"] = relationship("Project", back_populates="brief")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
消息/通知模型
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import String, Boolean, Text, ForeignKey, Index
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base, TimestampMixin):
|
||||||
|
"""消息表"""
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
|
||||||
|
# 接收者
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 消息类型: invite, new_task, pass, reject, appeal, system 等
|
||||||
|
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
|
||||||
|
# 消息内容
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|
||||||
|
# 已读状态
|
||||||
|
is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# 关联信息(可选)
|
||||||
|
related_task_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
related_project_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
sender_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_messages_user_id", "user_id"),
|
||||||
|
Index("idx_messages_user_read", "user_id", "is_read"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Message(id={self.id}, user_id={self.user_id}, type={self.type})>"
|
||||||
@@ -45,6 +45,9 @@ class Project(Base, TimestampMixin):
|
|||||||
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
deadline: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
deadline: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# 发布平台 (douyin/xiaohongshu/bilibili/kuaishou 等)
|
||||||
|
platform: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
|
||||||
|
|
||||||
# 状态
|
# 状态
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class ReviewTask(Base, TimestampMixin):
|
|||||||
# 视频信息
|
# 视频信息
|
||||||
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||||
platform: Mapped[Platform] = mapped_column(
|
platform: Mapped[Platform] = mapped_column(
|
||||||
SQLEnum(Platform, name="platform_enum"),
|
SQLEnum(Platform, name="platform_enum", values_callable=lambda x: [e.value for e in x]),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
@@ -55,7 +55,7 @@ class ReviewTask(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 审核状态
|
# 审核状态
|
||||||
status: Mapped[TaskStatus] = mapped_column(
|
status: Mapped[TaskStatus] = mapped_column(
|
||||||
SQLEnum(TaskStatus, name="task_status_enum"),
|
SQLEnum(TaskStatus, name="task_status_enum", values_callable=lambda x: [e.value for e in x]),
|
||||||
default=TaskStatus.PENDING,
|
default=TaskStatus.PENDING,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
规则模型
|
规则模型
|
||||||
违禁词、白名单、竞品
|
违禁词、白名单、竞品、平台规则
|
||||||
"""
|
"""
|
||||||
|
import enum
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
from sqlalchemy import String, Text, ForeignKey
|
from sqlalchemy import String, Text, ForeignKey
|
||||||
from app.models.types import JSONType
|
from app.models.types import JSONType
|
||||||
@@ -13,6 +14,13 @@ if TYPE_CHECKING:
|
|||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class RuleStatus(str, enum.Enum):
|
||||||
|
"""平台规则状态"""
|
||||||
|
DRAFT = "draft" # AI 解析完成,待确认
|
||||||
|
ACTIVE = "active" # 品牌方已确认,生效中
|
||||||
|
INACTIVE = "inactive" # 已停用
|
||||||
|
|
||||||
|
|
||||||
class ForbiddenWord(Base, TimestampMixin):
|
class ForbiddenWord(Base, TimestampMixin):
|
||||||
"""违禁词表"""
|
"""违禁词表"""
|
||||||
__tablename__ = "forbidden_words"
|
__tablename__ = "forbidden_words"
|
||||||
@@ -83,3 +91,36 @@ class Competitor(Base, TimestampMixin):
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Competitor(name={self.name}, brand_id={self.brand_id})>"
|
return f"<Competitor(name={self.name}, brand_id={self.brand_id})>"
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformRule(Base, TimestampMixin):
|
||||||
|
"""平台规则表 — 品牌方上传文档 + AI 解析"""
|
||||||
|
__tablename__ = "platform_rules"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
platform: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
|
||||||
|
# 文档信息
|
||||||
|
document_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||||
|
document_name: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
|
||||||
|
# AI 解析结果(JSON)
|
||||||
|
parsed_rules: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default=RuleStatus.DRAFT.value, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="platform_rules")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<PlatformRule(id={self.id}, platform={self.platform}, status={self.status})>"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class Task(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 当前阶段
|
# 当前阶段
|
||||||
stage: Mapped[TaskStage] = mapped_column(
|
stage: Mapped[TaskStage] = mapped_column(
|
||||||
SQLEnum(TaskStage, name="task_stage_enum"),
|
SQLEnum(TaskStage, name="task_stage_enum", values_callable=lambda x: [e.value for e in x]),
|
||||||
default=TaskStage.SCRIPT_UPLOAD,
|
default=TaskStage.SCRIPT_UPLOAD,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
@@ -88,7 +88,7 @@ class Task(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 脚本代理商审核
|
# 脚本代理商审核
|
||||||
script_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
script_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||||
SQLEnum(TaskStatus, name="task_status_enum"),
|
SQLEnum(TaskStatus, name="task_status_enum", values_callable=lambda x: [e.value for e in x]),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
script_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
script_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
@@ -97,7 +97,7 @@ class Task(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 脚本品牌方终审
|
# 脚本品牌方终审
|
||||||
script_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
script_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
|
SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
script_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
script_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
@@ -118,7 +118,7 @@ class Task(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 视频代理商审核
|
# 视频代理商审核
|
||||||
video_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
video_agency_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
|
SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
video_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
video_agency_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
@@ -127,7 +127,7 @@ class Task(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 视频品牌方终审
|
# 视频品牌方终审
|
||||||
video_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
video_brand_status: Mapped[Optional[TaskStatus]] = mapped_column(
|
||||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
|
SQLEnum(TaskStatus, name="task_status_enum", create_type=False, values_callable=lambda x: [e.value for e in x]),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
video_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
video_brand_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.models.base import Base, TimestampMixin
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.models.ai_config import AIConfig
|
from app.models.ai_config import AIConfig
|
||||||
from app.models.review import ReviewTask
|
from app.models.review import ReviewTask
|
||||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor, PlatformRule
|
||||||
|
|
||||||
|
|
||||||
class Tenant(Base, TimestampMixin):
|
class Tenant(Base, TimestampMixin):
|
||||||
@@ -48,5 +48,11 @@ class Tenant(Base, TimestampMixin):
|
|||||||
back_populates="tenant",
|
back_populates="tenant",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
)
|
)
|
||||||
|
platform_rules: Mapped[list["PlatformRule"]] = relationship(
|
||||||
|
"PlatformRule",
|
||||||
|
back_populates="tenant",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class User(Base, TimestampMixin):
|
|||||||
|
|
||||||
# 角色
|
# 角色
|
||||||
role: Mapped[UserRole] = mapped_column(
|
role: Mapped[UserRole] = mapped_column(
|
||||||
SQLEnum(UserRole, name="user_role_enum"),
|
SQLEnum(UserRole, name="user_role_enum", values_callable=lambda x: [e.value for e in x]),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Brief 相关 Schema
|
Brief 相关 Schema
|
||||||
|
|
||||||
|
卖点格式 (selling_points: List[dict]):
|
||||||
|
新格式: {"content": "卖点内容", "priority": "core|recommended|reference"}
|
||||||
|
旧格式: {"content": "卖点内容", "required": true|false}
|
||||||
|
兼容规则: required=true → priority="core", required=false → priority="recommended"
|
||||||
"""
|
"""
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -20,6 +25,7 @@ class BriefCreateRequest(BaseModel):
|
|||||||
max_duration: Optional[int] = None
|
max_duration: Optional[int] = None
|
||||||
other_requirements: Optional[str] = None
|
other_requirements: Optional[str] = None
|
||||||
attachments: Optional[List[dict]] = None
|
attachments: Optional[List[dict]] = None
|
||||||
|
agency_attachments: Optional[List[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
class BriefUpdateRequest(BaseModel):
|
class BriefUpdateRequest(BaseModel):
|
||||||
@@ -34,6 +40,17 @@ class BriefUpdateRequest(BaseModel):
|
|||||||
max_duration: Optional[int] = None
|
max_duration: Optional[int] = None
|
||||||
other_requirements: Optional[str] = None
|
other_requirements: Optional[str] = None
|
||||||
attachments: Optional[List[dict]] = None
|
attachments: Optional[List[dict]] = None
|
||||||
|
agency_attachments: Optional[List[dict]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgencyBriefUpdateRequest(BaseModel):
|
||||||
|
"""代理商更新 Brief 请求(允许更新代理商附件 + 卖点 + 违禁词 + AI解析内容)"""
|
||||||
|
agency_attachments: Optional[List[dict]] = None
|
||||||
|
selling_points: Optional[List[dict]] = None
|
||||||
|
min_selling_points: Optional[int] = None
|
||||||
|
blacklist_words: Optional[List[dict]] = None
|
||||||
|
brand_tone: Optional[str] = None
|
||||||
|
other_requirements: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ===== 响应 =====
|
# ===== 响应 =====
|
||||||
@@ -46,6 +63,7 @@ class BriefResponse(BaseModel):
|
|||||||
file_url: Optional[str] = None
|
file_url: Optional[str] = None
|
||||||
file_name: Optional[str] = None
|
file_name: Optional[str] = None
|
||||||
selling_points: Optional[List[dict]] = None
|
selling_points: Optional[List[dict]] = None
|
||||||
|
min_selling_points: Optional[int] = None
|
||||||
blacklist_words: Optional[List[dict]] = None
|
blacklist_words: Optional[List[dict]] = None
|
||||||
competitors: Optional[List[str]] = None
|
competitors: Optional[List[str]] = None
|
||||||
brand_tone: Optional[str] = None
|
brand_tone: Optional[str] = None
|
||||||
@@ -53,6 +71,7 @@ class BriefResponse(BaseModel):
|
|||||||
max_duration: Optional[int] = None
|
max_duration: Optional[int] = None
|
||||||
other_requirements: Optional[str] = None
|
other_requirements: Optional[str] = None
|
||||||
attachments: Optional[List[dict]] = None
|
attachments: Optional[List[dict]] = None
|
||||||
|
agency_attachments: Optional[List[dict]] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
消息相关 Schema
|
||||||
|
"""
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
is_read: bool
|
||||||
|
related_task_id: Optional[str] = None
|
||||||
|
related_project_id: Optional[str] = None
|
||||||
|
sender_name: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageListResponse(BaseModel):
|
||||||
|
items: List[MessageResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadCountResponse(BaseModel):
|
||||||
|
count: int
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
用户资料相关 Schema
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 角色附加信息 =====
|
||||||
|
|
||||||
|
class BrandProfile(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
logo: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
contact_name: Optional[str] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
contact_email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgencyProfile(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
logo: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
contact_name: Optional[str] = None
|
||||||
|
contact_phone: Optional[str] = None
|
||||||
|
contact_email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreatorProfile(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
avatar: Optional[str] = None
|
||||||
|
bio: Optional[str] = None
|
||||||
|
douyin_account: Optional[str] = None
|
||||||
|
xiaohongshu_account: Optional[str] = None
|
||||||
|
bilibili_account: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 响应 =====
|
||||||
|
|
||||||
|
class ProfileResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
name: str
|
||||||
|
avatar: Optional[str] = None
|
||||||
|
role: str
|
||||||
|
is_verified: bool = False
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
brand: Optional[BrandProfile] = None
|
||||||
|
agency: Optional[AgencyProfile] = None
|
||||||
|
creator: Optional[CreatorProfile] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 请求 =====
|
||||||
|
|
||||||
|
class ProfileUpdateRequest(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, max_length=100)
|
||||||
|
avatar: Optional[str] = Field(None, max_length=2048)
|
||||||
|
phone: Optional[str] = Field(None, max_length=20)
|
||||||
|
# 品牌方/代理商字段
|
||||||
|
description: Optional[str] = None
|
||||||
|
contact_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
contact_phone: Optional[str] = Field(None, max_length=20)
|
||||||
|
contact_email: Optional[str] = Field(None, max_length=255)
|
||||||
|
# 达人字段
|
||||||
|
bio: Optional[str] = None
|
||||||
|
douyin_account: Optional[str] = Field(None, max_length=100)
|
||||||
|
xiaohongshu_account: Optional[str] = Field(None, max_length=100)
|
||||||
|
bilibili_account: Optional[str] = Field(None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str = Field(..., min_length=6)
|
||||||
|
new_password: str = Field(..., min_length=6)
|
||||||
@@ -12,6 +12,7 @@ class ProjectCreateRequest(BaseModel):
|
|||||||
"""创建项目请求(品牌方操作)"""
|
"""创建项目请求(品牌方操作)"""
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
start_date: Optional[datetime] = None
|
start_date: Optional[datetime] = None
|
||||||
deadline: Optional[datetime] = None
|
deadline: Optional[datetime] = None
|
||||||
agency_ids: Optional[List[str]] = None # 分配的代理商 ID 列表
|
agency_ids: Optional[List[str]] = None # 分配的代理商 ID 列表
|
||||||
@@ -21,6 +22,7 @@ class ProjectUpdateRequest(BaseModel):
|
|||||||
"""更新项目请求"""
|
"""更新项目请求"""
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
start_date: Optional[datetime] = None
|
start_date: Optional[datetime] = None
|
||||||
deadline: Optional[datetime] = None
|
deadline: Optional[datetime] = None
|
||||||
status: Optional[str] = Field(None, pattern="^(active|completed|archived)$")
|
status: Optional[str] = Field(None, pattern="^(active|completed|archived)$")
|
||||||
@@ -45,6 +47,7 @@ class ProjectResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
brand_id: str
|
brand_id: str
|
||||||
brand_name: Optional[str] = None
|
brand_name: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class Violation(BaseModel):
|
|||||||
content: str = Field(..., description="违规内容")
|
content: str = Field(..., description="违规内容")
|
||||||
severity: RiskLevel = Field(..., description="严重程度")
|
severity: RiskLevel = Field(..., description="严重程度")
|
||||||
suggestion: str = Field(..., description="修改建议")
|
suggestion: str = Field(..., description="修改建议")
|
||||||
|
dimension: Optional[str] = Field(None, description="所属维度: legal/platform/brand_safety/brief_match")
|
||||||
|
|
||||||
# 文本审核字段
|
# 文本审核字段
|
||||||
position: Optional[Position] = Field(None, description="文本位置(脚本审核)")
|
position: Optional[Position] = Field(None, description="文本位置(脚本审核)")
|
||||||
@@ -101,6 +102,45 @@ class Violation(BaseModel):
|
|||||||
source: Optional[ViolationSource] = Field(None, description="违规来源(视频审核)")
|
source: Optional[ViolationSource] = Field(None, description="违规来源(视频审核)")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 多维度审核 ====================
|
||||||
|
|
||||||
|
class ReviewDimension(BaseModel):
|
||||||
|
"""审核维度评分"""
|
||||||
|
score: int = Field(..., ge=0, le=100)
|
||||||
|
passed: bool
|
||||||
|
issue_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewDimensions(BaseModel):
|
||||||
|
"""四维度审核结果"""
|
||||||
|
legal: ReviewDimension # 法规合规(违禁词、功效词、Brief黑名单词)
|
||||||
|
platform: ReviewDimension # 平台规则
|
||||||
|
brand_safety: ReviewDimension # 品牌安全(竞品、其他品牌词)
|
||||||
|
brief_match: ReviewDimension # Brief 匹配度(卖点覆盖)
|
||||||
|
|
||||||
|
|
||||||
|
class SellingPointMatch(BaseModel):
|
||||||
|
"""卖点匹配结果"""
|
||||||
|
content: str
|
||||||
|
priority: str # "core" | "recommended" | "reference"
|
||||||
|
matched: bool
|
||||||
|
evidence: Optional[str] = None # AI 给出的匹配依据
|
||||||
|
|
||||||
|
|
||||||
|
class BriefMatchDetail(BaseModel):
|
||||||
|
"""Brief 匹配度评分详情"""
|
||||||
|
# 卖点覆盖
|
||||||
|
total_points: int = Field(0, description="需要检查的卖点总数(core + recommended)")
|
||||||
|
matched_points: int = Field(0, description="实际匹配的卖点数")
|
||||||
|
required_points: int = Field(0, description="代理商要求至少体现的卖点条数(min_selling_points)")
|
||||||
|
coverage_score: int = Field(0, ge=0, le=100, description="卖点覆盖率得分")
|
||||||
|
# AI 整体匹配分析
|
||||||
|
overall_score: int = Field(0, ge=0, le=100, description="整体 Brief 匹配度得分")
|
||||||
|
highlights: list[str] = Field(default_factory=list, description="内容亮点(AI 分析)")
|
||||||
|
issues: list[str] = Field(default_factory=list, description="问题点(AI 分析)")
|
||||||
|
explanation: str = Field("", description="评分说明(一句话总结)")
|
||||||
|
|
||||||
|
|
||||||
# ==================== 脚本预审 ====================
|
# ==================== 脚本预审 ====================
|
||||||
|
|
||||||
class ScriptReviewRequest(BaseModel):
|
class ScriptReviewRequest(BaseModel):
|
||||||
@@ -108,8 +148,12 @@ class ScriptReviewRequest(BaseModel):
|
|||||||
content: str = Field(..., min_length=1, description="脚本内容")
|
content: str = Field(..., min_length=1, description="脚本内容")
|
||||||
platform: Platform = Field(..., description="投放平台")
|
platform: Platform = Field(..., description="投放平台")
|
||||||
brand_id: str = Field(..., description="品牌 ID")
|
brand_id: str = Field(..., description="品牌 ID")
|
||||||
required_points: Optional[list[str]] = Field(None, description="必要卖点列表")
|
selling_points: Optional[list[dict]] = Field(None, description="卖点列表 [{content, priority}]")
|
||||||
|
min_selling_points: Optional[int] = Field(None, ge=0, description="代理商要求至少体现的卖点条数")
|
||||||
|
blacklist_words: Optional[list[dict]] = Field(None, description="Brief 黑名单词 [{word, reason}]")
|
||||||
soft_risk_context: Optional[SoftRiskContext] = Field(None, description="软性风控上下文")
|
soft_risk_context: Optional[SoftRiskContext] = Field(None, description="软性风控上下文")
|
||||||
|
file_url: Optional[str] = Field(None, description="脚本文件 URL(用于自动解析文本和提取图片)")
|
||||||
|
file_name: Optional[str] = Field(None, description="原始文件名(用于判断格式)")
|
||||||
|
|
||||||
|
|
||||||
class ScriptReviewResponse(BaseModel):
|
class ScriptReviewResponse(BaseModel):
|
||||||
@@ -117,16 +161,22 @@ class ScriptReviewResponse(BaseModel):
|
|||||||
脚本预审响应
|
脚本预审响应
|
||||||
|
|
||||||
结构:
|
结构:
|
||||||
- score: 合规分数 0-100
|
- score: 加权总分(向后兼容)
|
||||||
- summary: 整体摘要
|
- summary: 整体摘要
|
||||||
- violations: 违规项列表,每项包含 suggestion
|
- dimensions: 四维度评分(法规/平台/品牌安全/Brief匹配)
|
||||||
- missing_points: 遗漏的卖点(可选)
|
- selling_point_matches: 卖点匹配详情
|
||||||
|
- violations: 违规项列表,每项带 dimension 标签
|
||||||
|
- missing_points: 遗漏的核心卖点(向后兼容)
|
||||||
"""
|
"""
|
||||||
score: int = Field(..., ge=0, le=100, description="合规分数")
|
score: int = Field(..., ge=0, le=100, description="加权总分")
|
||||||
summary: str = Field(..., description="审核摘要")
|
summary: str = Field(..., description="审核摘要")
|
||||||
|
dimensions: ReviewDimensions = Field(..., description="四维度评分")
|
||||||
|
selling_point_matches: list[SellingPointMatch] = Field(default_factory=list, description="卖点匹配详情")
|
||||||
|
brief_match_detail: Optional[BriefMatchDetail] = Field(None, description="Brief 匹配度评分详情")
|
||||||
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
||||||
missing_points: Optional[list[str]] = Field(None, description="遗漏的卖点")
|
missing_points: Optional[list[str]] = Field(None, description="遗漏的核心卖点")
|
||||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||||
|
ai_available: bool = Field(True, description="AI 服务是否可用(False 表示降级为纯关键词检测)")
|
||||||
|
|
||||||
|
|
||||||
# ==================== 视频审核 ====================
|
# ==================== 视频审核 ====================
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
平台规则相关 Schema
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformRuleParseRequest(BaseModel):
|
||||||
|
"""上传文档并解析"""
|
||||||
|
document_url: str = Field(..., description="TOS 上传后的文件 URL")
|
||||||
|
document_name: str = Field(..., description="原始文件名(用于判断格式)")
|
||||||
|
platform: str = Field(..., description="目标平台 (douyin/xiaohongshu/bilibili/kuaishou)")
|
||||||
|
brand_id: str = Field(..., description="品牌 ID")
|
||||||
|
|
||||||
|
|
||||||
|
class ParsedRulesData(BaseModel):
|
||||||
|
"""AI 解析出的结构化规则"""
|
||||||
|
forbidden_words: list[str] = Field(default_factory=list, description="违禁词列表")
|
||||||
|
restricted_words: list[dict] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="限制词 [{word, condition, suggestion}]",
|
||||||
|
)
|
||||||
|
duration: Optional[dict] = Field(
|
||||||
|
None,
|
||||||
|
description="时长要求 {min_seconds, max_seconds}",
|
||||||
|
)
|
||||||
|
content_requirements: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="内容要求(如'必须展示产品')",
|
||||||
|
)
|
||||||
|
other_rules: list[dict] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="其他规则 [{rule, description}]",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformRuleParseResponse(BaseModel):
|
||||||
|
"""解析响应(draft 状态)"""
|
||||||
|
id: str
|
||||||
|
platform: str
|
||||||
|
brand_id: str
|
||||||
|
document_url: str
|
||||||
|
document_name: str
|
||||||
|
parsed_rules: ParsedRulesData
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformRuleConfirmRequest(BaseModel):
|
||||||
|
"""确认/编辑解析结果"""
|
||||||
|
parsed_rules: ParsedRulesData = Field(..., description="品牌方可能修改过的规则")
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformRuleResponse(BaseModel):
|
||||||
|
"""完整响应"""
|
||||||
|
id: str
|
||||||
|
platform: str
|
||||||
|
brand_id: str
|
||||||
|
document_url: str
|
||||||
|
document_name: str
|
||||||
|
parsed_rules: ParsedRulesData
|
||||||
|
status: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformRuleListResponse(BaseModel):
|
||||||
|
"""列表响应"""
|
||||||
|
items: list[PlatformRuleResponse]
|
||||||
|
total: int
|
||||||
@@ -87,6 +87,7 @@ class ProjectInfo(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
brand_name: Optional[str] = None
|
brand_name: Optional[str] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskResponse(BaseModel):
|
class TaskResponse(BaseModel):
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ class OpenAICompatibleClient:
|
|||||||
base_url: str,
|
base_url: str,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
provider: str = "openai",
|
provider: str = "openai",
|
||||||
timeout: float = 60.0,
|
timeout: float = 180.0,
|
||||||
):
|
):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
|
# 自动补全 /v1 后缀(OpenAI SDK 需要完整路径)
|
||||||
|
if not self.base_url.endswith("/v1"):
|
||||||
|
self.base_url = self.base_url + "/v1"
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|||||||
@@ -53,18 +53,24 @@ class AIServiceFactory:
|
|||||||
)
|
)
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not config:
|
if config:
|
||||||
return None
|
|
||||||
|
|
||||||
# 解密 API Key
|
# 解密 API Key
|
||||||
api_key = decrypt_api_key(config.api_key_encrypted)
|
api_key = decrypt_api_key(config.api_key_encrypted)
|
||||||
|
|
||||||
# 创建客户端
|
|
||||||
client = OpenAICompatibleClient(
|
client = OpenAICompatibleClient(
|
||||||
base_url=config.base_url,
|
base_url=config.base_url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
provider=config.provider,
|
provider=config.provider,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# 回退到全局 .env 配置
|
||||||
|
from app.config import settings
|
||||||
|
if not settings.AI_API_KEY or not settings.AI_API_BASE_URL:
|
||||||
|
return None
|
||||||
|
client = OpenAICompatibleClient(
|
||||||
|
base_url=settings.AI_API_BASE_URL,
|
||||||
|
api_key=settings.AI_API_KEY,
|
||||||
|
provider=settings.AI_PROVIDER,
|
||||||
|
)
|
||||||
|
|
||||||
# 缓存客户端
|
# 缓存客户端
|
||||||
cls._cache[cache_key] = client
|
cls._cache[cache_key] = client
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import secrets
|
import secrets
|
||||||
from jose import jwt, JWTError
|
import jwt
|
||||||
|
from jwt.exceptions import PyJWTError as JWTError
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""
|
||||||
|
文档解析服务
|
||||||
|
从 PDF/Word/Excel 文档中提取纯文本
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentParser:
|
||||||
|
"""从文档中提取纯文本"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def download_and_parse(document_url: str, document_name: str) -> str:
|
||||||
|
"""
|
||||||
|
下载文档并解析为纯文本
|
||||||
|
|
||||||
|
优先使用 TOS SDK 直接下载(私有桶无需签名),
|
||||||
|
回退到 HTTP 预签名 URL 下载。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_url: 文档 URL (TOS)
|
||||||
|
document_name: 原始文件名(用于判断格式)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
提取的纯文本
|
||||||
|
"""
|
||||||
|
tmp_path: Optional[str] = None
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 跳过过大的文件(>20MB),解析可能非常慢且阻塞
|
||||||
|
if len(content) > 20 * 1024 * 1024:
|
||||||
|
logger.warning(f"文件 {document_name} 过大 ({len(content)//1024//1024}MB),已跳过")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
|
||||||
|
tmp.write(content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
# 文件解析可能很慢(CPU 密集),放到线程池执行
|
||||||
|
return await asyncio.to_thread(DocumentParser.parse_file, tmp_path, document_name)
|
||||||
|
finally:
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
# 图片提取限制
|
||||||
|
MAX_IMAGES = 10
|
||||||
|
MAX_IMAGE_SIZE = 2 * 1024 * 1024 # 2MB per image base64
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def download_and_get_images(document_url: str, document_name: str) -> Optional[list[str]]:
|
||||||
|
"""
|
||||||
|
下载文档并提取嵌入的图片,返回 base64 编码列表。
|
||||||
|
|
||||||
|
支持格式:
|
||||||
|
- PDF: 图片型 PDF 转页面图片
|
||||||
|
- DOCX: 提取 word/media/ 中的嵌入图片
|
||||||
|
- XLSX: 提取 worksheet 中的嵌入图片
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
base64 图片列表,无图片时返回 None
|
||||||
|
"""
|
||||||
|
ext = document_name.rsplit(".", 1)[-1].lower() if "." in document_name else ""
|
||||||
|
if ext not in ("pdf", "doc", "docx", "xls", "xlsx"):
|
||||||
|
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=f".{ext}") as tmp:
|
||||||
|
tmp.write(file_content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
if ext == "pdf":
|
||||||
|
if DocumentParser.is_image_pdf(tmp_path):
|
||||||
|
return DocumentParser.pdf_to_images_base64(tmp_path)
|
||||||
|
return None
|
||||||
|
elif ext in ("doc", "docx"):
|
||||||
|
images = await asyncio.to_thread(DocumentParser._extract_docx_images, tmp_path)
|
||||||
|
return images if images else None
|
||||||
|
elif ext in ("xls", "xlsx"):
|
||||||
|
images = await asyncio.to_thread(DocumentParser._extract_xlsx_images, tmp_path)
|
||||||
|
return images if images else None
|
||||||
|
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 直接下载文件(私有桶安全访问),在线程池中执行避免阻塞"""
|
||||||
|
def _sync_download() -> Optional[bytes]:
|
||||||
|
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:
|
||||||
|
logger.debug("TOS SDK: AK/SK 未配置,跳过")
|
||||||
|
return None
|
||||||
|
|
||||||
|
file_key = parse_file_key_from_url(document_url)
|
||||||
|
if not file_key or file_key == document_url:
|
||||||
|
logger.debug(f"TOS SDK: 无法从 URL 解析 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)
|
||||||
|
data = resp.read()
|
||||||
|
logger.info(f"TOS SDK: 下载成功, key={file_key}, size={len(data)}")
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"TOS SDK 下载失败,将回退 HTTP: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_sync_download)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
logger.info(f"HTTP 签名 URL 下载: key={file_key}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.get(signed_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logger.info(f"HTTP 下载成功: {len(resp.content)} bytes")
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_file(file_path: str, file_name: str) -> str:
|
||||||
|
"""
|
||||||
|
根据扩展名选择解析器,返回纯文本
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 本地文件路径
|
||||||
|
file_name: 原始文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
提取的纯文本
|
||||||
|
"""
|
||||||
|
ext = file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
|
||||||
|
|
||||||
|
if ext == "pdf":
|
||||||
|
return DocumentParser._parse_pdf(file_path)
|
||||||
|
elif ext in ("doc", "docx"):
|
||||||
|
return DocumentParser._parse_docx(file_path)
|
||||||
|
elif ext in ("xls", "xlsx"):
|
||||||
|
return DocumentParser._parse_xlsx(file_path)
|
||||||
|
elif ext == "txt":
|
||||||
|
return DocumentParser._parse_txt(file_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的文件格式: {ext}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_pdf(path: str) -> str:
|
||||||
|
"""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:
|
||||||
|
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:
|
||||||
|
"""python-docx 提取 Word 文本"""
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
doc = Document(path)
|
||||||
|
texts = []
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
if para.text.strip():
|
||||||
|
texts.append(para.text)
|
||||||
|
# 也提取表格内容
|
||||||
|
for table in doc.tables:
|
||||||
|
for row in table.rows:
|
||||||
|
row_text = "\t".join(cell.text.strip() for cell in row.cells if cell.text.strip())
|
||||||
|
if row_text:
|
||||||
|
texts.append(row_text)
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_xlsx(path: str) -> str:
|
||||||
|
"""openpyxl 提取 Excel 文本(所有 sheet 拼接)"""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
wb = load_workbook(path, read_only=True, data_only=True)
|
||||||
|
texts = []
|
||||||
|
for sheet in wb.worksheets:
|
||||||
|
for row in sheet.iter_rows(values_only=True):
|
||||||
|
row_text = "\t".join(str(cell) for cell in row if cell is not None)
|
||||||
|
if row_text.strip():
|
||||||
|
texts.append(row_text)
|
||||||
|
wb.close()
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_txt(path: str) -> str:
|
||||||
|
"""纯文本文件"""
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_docx_images(path: str) -> list[str]:
|
||||||
|
"""从 DOCX 文件中提取嵌入图片(DOCX 本质是 ZIP,图片在 word/media/ 目录)"""
|
||||||
|
import zipfile
|
||||||
|
import base64
|
||||||
|
|
||||||
|
images = []
|
||||||
|
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(path, "r") as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if not name.startswith("word/media/"):
|
||||||
|
continue
|
||||||
|
ext = os.path.splitext(name)[1].lower()
|
||||||
|
if ext not in image_exts:
|
||||||
|
continue
|
||||||
|
img_data = zf.read(name)
|
||||||
|
b64 = base64.b64encode(img_data).decode()
|
||||||
|
if len(b64) > DocumentParser.MAX_IMAGE_SIZE:
|
||||||
|
logger.debug(f"跳过过大图片: {name} ({len(b64)} bytes)")
|
||||||
|
continue
|
||||||
|
images.append(b64)
|
||||||
|
if len(images) >= DocumentParser.MAX_IMAGES:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"提取 DOCX 图片失败: {e}")
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_xlsx_images(path: str) -> list[str]:
|
||||||
|
"""从 XLSX 文件中提取嵌入图片(通过 openpyxl 的 _images 属性)"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
images = []
|
||||||
|
try:
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
wb = load_workbook(path, read_only=False)
|
||||||
|
for sheet in wb.worksheets:
|
||||||
|
for img in getattr(sheet, "_images", []):
|
||||||
|
try:
|
||||||
|
img_data = img._data()
|
||||||
|
b64 = base64.b64encode(img_data).decode()
|
||||||
|
if len(b64) > DocumentParser.MAX_IMAGE_SIZE:
|
||||||
|
continue
|
||||||
|
images.append(b64)
|
||||||
|
if len(images) >= DocumentParser.MAX_IMAGES:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if len(images) >= DocumentParser.MAX_IMAGES:
|
||||||
|
break
|
||||||
|
wb.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"提取 XLSX 图片失败: {e}")
|
||||||
|
|
||||||
|
return images
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
消息服务
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from typing import Optional, Tuple, List
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, update
|
||||||
|
|
||||||
|
from app.models.message import Message
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_message_id() -> str:
|
||||||
|
"""生成消息 ID"""
|
||||||
|
random_part = secrets.randbelow(900000) + 100000
|
||||||
|
return f"MSG{random_part}"
|
||||||
|
|
||||||
|
|
||||||
|
async def create_message(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
type: str,
|
||||||
|
title: str,
|
||||||
|
content: str,
|
||||||
|
related_task_id: Optional[str] = None,
|
||||||
|
related_project_id: Optional[str] = None,
|
||||||
|
sender_name: Optional[str] = None,
|
||||||
|
) -> Message:
|
||||||
|
"""创建消息"""
|
||||||
|
message = Message(
|
||||||
|
id=_generate_message_id(),
|
||||||
|
user_id=user_id,
|
||||||
|
type=type,
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
is_read=False,
|
||||||
|
related_task_id=related_task_id,
|
||||||
|
related_project_id=related_project_id,
|
||||||
|
sender_name=sender_name,
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
await db.flush()
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
async def list_messages(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
is_read: Optional[bool] = None,
|
||||||
|
type: Optional[str] = None,
|
||||||
|
) -> Tuple[List[Message], int]:
|
||||||
|
"""查询消息列表"""
|
||||||
|
query = select(Message).where(Message.user_id == user_id)
|
||||||
|
count_query = select(func.count()).select_from(Message).where(Message.user_id == user_id)
|
||||||
|
|
||||||
|
if is_read is not None:
|
||||||
|
query = query.where(Message.is_read == is_read)
|
||||||
|
count_query = count_query.where(Message.is_read == is_read)
|
||||||
|
|
||||||
|
if type is not None:
|
||||||
|
query = query.where(Message.type == type)
|
||||||
|
count_query = count_query.where(Message.type == type)
|
||||||
|
|
||||||
|
# 总数
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
query = query.order_by(Message.created_at.desc())
|
||||||
|
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
messages = list(result.scalars().all())
|
||||||
|
|
||||||
|
return messages, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_unread_count(db: AsyncSession, user_id: str) -> int:
|
||||||
|
"""获取未读消息数"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count()).select_from(Message).where(
|
||||||
|
Message.user_id == user_id,
|
||||||
|
Message.is_read == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_as_read(db: AsyncSession, message_id: str, user_id: str) -> bool:
|
||||||
|
"""标记单条消息已读"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Message).where(
|
||||||
|
Message.id == message_id,
|
||||||
|
Message.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message = result.scalar_one_or_none()
|
||||||
|
if not message:
|
||||||
|
return False
|
||||||
|
|
||||||
|
message.is_read = True
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_all_as_read(db: AsyncSession, user_id: str) -> int:
|
||||||
|
"""标记所有消息已读,返回更新数量"""
|
||||||
|
result = await db.execute(
|
||||||
|
update(Message)
|
||||||
|
.where(Message.user_id == user_id, Message.is_read == False)
|
||||||
|
.values(is_read=True)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
return result.rowcount
|
||||||
+170
-67
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
阿里云 OSS 服务
|
火山引擎 TOS (Volcengine Object Storage) 服务 — 表单直传签名 (V4)
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import hmac
|
import hmac
|
||||||
@@ -7,7 +7,7 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -17,99 +17,114 @@ def generate_upload_policy(
|
|||||||
upload_dir: Optional[str] = None,
|
upload_dir: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
生成前端直传 OSS 所需的 Policy 和签名
|
生成前端直传 TOS 所需的 Policy 和签名 (V4 HMAC-SHA256)
|
||||||
|
|
||||||
|
TOS 表单直传签名流程 (PostObject):
|
||||||
|
1. 构建 policy JSON → Base64 编码
|
||||||
|
2. 派生签名密钥: kDate → kRegion → kService → kSigning
|
||||||
|
3. signature = HMAC-SHA256(kSigning, policy_base64)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{
|
{
|
||||||
"accessKeyId": "...",
|
"x_tos_algorithm": "TOS4-HMAC-SHA256",
|
||||||
|
"x_tos_credential": "AKIDxxxx/20260210/cn-beijing/tos/request",
|
||||||
|
"x_tos_date": "20260210T120000Z",
|
||||||
|
"x_tos_signature": "...",
|
||||||
"policy": "base64 encoded policy",
|
"policy": "base64 encoded policy",
|
||||||
"signature": "...",
|
"host": "https://bucket.tos-cn-beijing.volces.com",
|
||||||
"host": "https://bucket.oss-cn-hangzhou.aliyuncs.com",
|
|
||||||
"dir": "uploads/2026/02/",
|
"dir": "uploads/2026/02/",
|
||||||
"expire": 1234567890
|
"expire": 1234567890,
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET:
|
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||||
raise ValueError("OSS 配置未设置")
|
raise ValueError("TOS 配置未设置")
|
||||||
|
|
||||||
# 计算过期时间
|
# 计算时间
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
date_stamp = now_utc.strftime("%Y%m%d") # 20260210
|
||||||
|
tos_date = now_utc.strftime("%Y%m%dT%H%M%SZ") # 20260210T120000Z
|
||||||
expire_time = int(time.time()) + expire_seconds
|
expire_time = int(time.time()) + expire_seconds
|
||||||
expire_date = datetime.utcfromtimestamp(expire_time).strftime("%Y-%m-%dT%H:%M:%SZ")
|
expiration = datetime.fromtimestamp(expire_time, tz=timezone.utc).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%S.000Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Credential scope
|
||||||
|
region = settings.TOS_REGION
|
||||||
|
credential = f"{settings.TOS_ACCESS_KEY_ID}/{date_stamp}/{region}/tos/request"
|
||||||
|
|
||||||
# 默认上传目录:uploads/年/月/
|
# 默认上传目录:uploads/年/月/
|
||||||
if upload_dir is None:
|
if upload_dir is None:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
|
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
|
||||||
|
|
||||||
# 构建 Policy
|
# 1. 构建 Policy
|
||||||
policy_dict = {
|
policy_dict = {
|
||||||
"expiration": expire_date,
|
"expiration": expiration,
|
||||||
"conditions": [
|
"conditions": [
|
||||||
{"bucket": settings.OSS_BUCKET_NAME},
|
{"bucket": settings.TOS_BUCKET_NAME},
|
||||||
["starts-with", "$key", upload_dir],
|
["starts-with", "$key", upload_dir],
|
||||||
|
{"x-tos-algorithm": "TOS4-HMAC-SHA256"},
|
||||||
|
{"x-tos-credential": credential},
|
||||||
|
{"x-tos-date": tos_date},
|
||||||
["content-length-range", 0, max_size_mb * 1024 * 1024],
|
["content-length-range", 0, max_size_mb * 1024 * 1024],
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Base64 编码 Policy
|
# 2. Base64 编码 Policy
|
||||||
policy_json = json.dumps(policy_dict)
|
policy_json = json.dumps(policy_dict)
|
||||||
policy_base64 = base64.b64encode(policy_json.encode()).decode()
|
policy_base64 = base64.b64encode(policy_json.encode()).decode()
|
||||||
|
|
||||||
# 计算签名
|
# 3. 派生签名密钥 (V4 Signing Key)
|
||||||
signature = base64.b64encode(
|
k_date = hmac.new(
|
||||||
hmac.new(
|
f"TOS4{settings.TOS_SECRET_ACCESS_KEY}".encode(),
|
||||||
settings.OSS_ACCESS_KEY_SECRET.encode(),
|
date_stamp.encode(),
|
||||||
policy_base64.encode(),
|
hashlib.sha256,
|
||||||
hashlib.sha1
|
|
||||||
).digest()
|
).digest()
|
||||||
).decode()
|
|
||||||
|
k_region = hmac.new(k_date, region.encode(), hashlib.sha256).digest()
|
||||||
|
k_service = hmac.new(k_region, b"tos", hashlib.sha256).digest()
|
||||||
|
k_signing = hmac.new(k_service, b"request", hashlib.sha256).digest()
|
||||||
|
|
||||||
|
# 4. signature = HMAC-SHA256(kSigning, policy_base64)
|
||||||
|
signature = hmac.new(
|
||||||
|
k_signing,
|
||||||
|
policy_base64.encode(),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
# 构建 Host
|
# 构建 Host
|
||||||
host = settings.OSS_BUCKET_DOMAIN
|
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||||
if not host:
|
host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
|
||||||
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"accessKeyId": settings.OSS_ACCESS_KEY_ID,
|
"x_tos_algorithm": "TOS4-HMAC-SHA256",
|
||||||
|
"x_tos_credential": credential,
|
||||||
|
"x_tos_date": tos_date,
|
||||||
|
"x_tos_signature": signature,
|
||||||
"policy": policy_base64,
|
"policy": policy_base64,
|
||||||
"signature": signature,
|
|
||||||
"host": host,
|
"host": host,
|
||||||
"dir": upload_dir,
|
"dir": upload_dir,
|
||||||
"expire": expire_time,
|
"expire": expire_time,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_sts_token(
|
|
||||||
role_arn: str,
|
|
||||||
session_name: str = "miaosi-upload",
|
|
||||||
duration_seconds: int = 3600,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
生成 STS 临时凭证(需要配置 RAM 角色)
|
|
||||||
|
|
||||||
当前使用 Policy 签名方式,STS 方式为可选增强。
|
|
||||||
如需启用 STS,请安装 aliyun-python-sdk-sts 并配置 RAM 角色。
|
|
||||||
"""
|
|
||||||
# 回退到 Policy 签名方式
|
|
||||||
return generate_upload_policy(
|
|
||||||
max_size_mb=settings.MAX_FILE_SIZE_MB,
|
|
||||||
expire_seconds=duration_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_file_url(file_key: str) -> str:
|
def get_file_url(file_key: str) -> str:
|
||||||
"""
|
"""
|
||||||
获取文件的公开访问 URL
|
获取文件的访问 URL
|
||||||
|
|
||||||
|
优先使用 CDN 域名,否则用 TOS 源站域名。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_key: 文件在 OSS 中的 key,如 "uploads/2026/02/video.mp4"
|
file_key: 文件在 TOS 中的 key,如 "uploads/2026/02/video.mp4"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
完整的访问 URL
|
完整的访问 URL
|
||||||
"""
|
"""
|
||||||
host = settings.OSS_BUCKET_DOMAIN
|
if settings.TOS_CDN_DOMAIN:
|
||||||
if not host:
|
host = settings.TOS_CDN_DOMAIN
|
||||||
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
else:
|
||||||
|
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{settings.TOS_REGION}.volces.com"
|
||||||
|
host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
|
||||||
|
|
||||||
# 确保 host 以 https:// 开头
|
# 确保 host 以 https:// 开头
|
||||||
if not host.startswith("http"):
|
if not host.startswith("http"):
|
||||||
@@ -124,31 +139,119 @@ def get_file_url(file_key: str) -> str:
|
|||||||
return f"{host}/{file_key}"
|
return f"{host}/{file_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_presigned_url(
|
||||||
|
file_key: str,
|
||||||
|
expire_seconds: int = 3600,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth)
|
||||||
|
|
||||||
|
签名流程:
|
||||||
|
1. 构建 CanonicalRequest
|
||||||
|
2. 构建 StringToSign
|
||||||
|
3. 用派生密钥签名
|
||||||
|
4. 拼接查询参数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_key: 文件在 TOS 中的 key
|
||||||
|
expire_seconds: URL 有效期(秒),默认 1 小时
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预签名 URL
|
||||||
|
"""
|
||||||
|
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||||
|
raise ValueError("TOS 配置未设置")
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
region = settings.TOS_REGION
|
||||||
|
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||||
|
bucket = settings.TOS_BUCKET_NAME
|
||||||
|
host = f"{bucket}.{endpoint}"
|
||||||
|
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
date_stamp = now_utc.strftime("%Y%m%d")
|
||||||
|
tos_date = now_utc.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
credential = f"{settings.TOS_ACCESS_KEY_ID}/{date_stamp}/{region}/tos/request"
|
||||||
|
|
||||||
|
# 对 file_key 中的路径段分别编码
|
||||||
|
encoded_key = "/".join(quote(seg, safe="") for seg in file_key.split("/"))
|
||||||
|
|
||||||
|
# 查询参数(按字母序排列)
|
||||||
|
query_params = (
|
||||||
|
f"X-Tos-Algorithm=TOS4-HMAC-SHA256"
|
||||||
|
f"&X-Tos-Credential={quote(credential, safe='')}"
|
||||||
|
f"&X-Tos-Date={tos_date}"
|
||||||
|
f"&X-Tos-Expires={expire_seconds}"
|
||||||
|
f"&X-Tos-SignedHeaders=host"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CanonicalRequest
|
||||||
|
canonical_request = (
|
||||||
|
f"GET\n"
|
||||||
|
f"/{encoded_key}\n"
|
||||||
|
f"{query_params}\n"
|
||||||
|
f"host:{host}\n"
|
||||||
|
f"\n"
|
||||||
|
f"host\n"
|
||||||
|
f"UNSIGNED-PAYLOAD"
|
||||||
|
)
|
||||||
|
|
||||||
|
# StringToSign
|
||||||
|
canonical_request_hash = hashlib.sha256(canonical_request.encode()).hexdigest()
|
||||||
|
string_to_sign = (
|
||||||
|
f"TOS4-HMAC-SHA256\n"
|
||||||
|
f"{tos_date}\n"
|
||||||
|
f"{date_stamp}/{region}/tos/request\n"
|
||||||
|
f"{canonical_request_hash}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 派生签名密钥
|
||||||
|
k_date = hmac.new(
|
||||||
|
f"TOS4{settings.TOS_SECRET_ACCESS_KEY}".encode(),
|
||||||
|
date_stamp.encode(),
|
||||||
|
hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
k_region = hmac.new(k_date, region.encode(), hashlib.sha256).digest()
|
||||||
|
k_service = hmac.new(k_region, b"tos", hashlib.sha256).digest()
|
||||||
|
k_signing = hmac.new(k_service, b"request", hashlib.sha256).digest()
|
||||||
|
|
||||||
|
# 计算签名
|
||||||
|
signature = hmac.new(
|
||||||
|
k_signing,
|
||||||
|
string_to_sign.encode(),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"https://{host}/{encoded_key}"
|
||||||
|
f"?{query_params}"
|
||||||
|
f"&X-Tos-Signature={signature}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_file_key_from_url(url: str) -> str:
|
def parse_file_key_from_url(url: str) -> str:
|
||||||
"""
|
"""
|
||||||
从完整 URL 解析出文件 key
|
从完整 URL 解析出文件 key
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: 完整的 OSS URL
|
url: 完整的 TOS URL
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
文件 key
|
文件 key
|
||||||
"""
|
"""
|
||||||
host = settings.OSS_BUCKET_DOMAIN
|
# 尝试移除 CDN 域名
|
||||||
if not host:
|
if settings.TOS_CDN_DOMAIN:
|
||||||
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
cdn = settings.TOS_CDN_DOMAIN.rstrip("/")
|
||||||
|
if not cdn.startswith("http"):
|
||||||
|
cdn = f"https://{cdn}"
|
||||||
|
if url.startswith(cdn):
|
||||||
|
return url[len(cdn):].lstrip("/")
|
||||||
|
|
||||||
# 移除 host 前缀
|
# 尝试移除 TOS 源站域名
|
||||||
if url.startswith(host):
|
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{settings.TOS_REGION}.volces.com"
|
||||||
return url[len(host):].lstrip("/")
|
tos_host = f"https://{settings.TOS_BUCKET_NAME}.{endpoint}"
|
||||||
|
if url.startswith(tos_host):
|
||||||
# 尝试其他格式
|
return url[len(tos_host):].lstrip("/")
|
||||||
if settings.OSS_BUCKET_NAME in url:
|
|
||||||
# 格式: https://bucket.endpoint/key
|
|
||||||
parts = url.split(settings.OSS_BUCKET_NAME + ".")
|
|
||||||
if len(parts) > 1:
|
|
||||||
key_part = parts[1].split("/", 1)
|
|
||||||
if len(key_part) > 1:
|
|
||||||
return key_part[1]
|
|
||||||
|
|
||||||
return url
|
return url
|
||||||
|
|||||||
@@ -195,6 +195,42 @@ async def upload_video(
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
AI_AUTO_REJECT_SCORE = 40
|
||||||
|
|
||||||
|
|
||||||
|
def _check_ai_auto_reject(score: int, result: dict) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
判断 AI 审核结果是否应自动驳回
|
||||||
|
|
||||||
|
触发条件(任一):
|
||||||
|
1. 法规合规维度存在 HIGH 级违规(违禁词/功效词)
|
||||||
|
2. 品牌安全维度存在 HIGH 级违规(竞品提及)
|
||||||
|
3. 总分 < 40
|
||||||
|
"""
|
||||||
|
reasons = []
|
||||||
|
violations = result.get("violations", [])
|
||||||
|
|
||||||
|
# 条件1: 法规 HIGH
|
||||||
|
high_legal = [v for v in violations if v.get("dimension") == "legal" and v.get("severity") == "high"]
|
||||||
|
if high_legal:
|
||||||
|
words = [v.get("content", "") for v in high_legal[:5]]
|
||||||
|
reasons.append(f"法规违规:{', '.join(words)}")
|
||||||
|
|
||||||
|
# 条件2: 品牌安全 HIGH
|
||||||
|
high_brand = [v for v in violations if v.get("dimension") == "brand_safety" and v.get("severity") == "high"]
|
||||||
|
if high_brand:
|
||||||
|
words = [v.get("content", "") for v in high_brand[:5]]
|
||||||
|
reasons.append(f"品牌安全违规:{', '.join(words)}")
|
||||||
|
|
||||||
|
# 条件3: 总分过低
|
||||||
|
if score < AI_AUTO_REJECT_SCORE:
|
||||||
|
reasons.append(f"综合评分 {score} 分,低于合格线 {AI_AUTO_REJECT_SCORE} 分")
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return True, ";".join(reasons)
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
async def complete_ai_review(
|
async def complete_ai_review(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
task: Task,
|
task: Task,
|
||||||
@@ -206,9 +242,16 @@ async def complete_ai_review(
|
|||||||
完成 AI 审核
|
完成 AI 审核
|
||||||
|
|
||||||
- 更新 AI 审核结果
|
- 更新 AI 审核结果
|
||||||
- 状态流转到代理商审核
|
- 自动驳回:法规/品牌安全 HIGH 违规或总分 < 40 → 回到上传阶段
|
||||||
|
- 正常:流转到代理商审核
|
||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
auto_rejected, reject_reason = _check_ai_auto_reject(score, result)
|
||||||
|
|
||||||
|
# 将自动驳回信息写入 result,前端可据此展示
|
||||||
|
if auto_rejected:
|
||||||
|
result["ai_auto_rejected"] = True
|
||||||
|
result["ai_reject_reason"] = reject_reason
|
||||||
|
|
||||||
if review_type == "script":
|
if review_type == "script":
|
||||||
if task.stage != TaskStage.SCRIPT_AI_REVIEW:
|
if task.stage != TaskStage.SCRIPT_AI_REVIEW:
|
||||||
@@ -217,6 +260,10 @@ async def complete_ai_review(
|
|||||||
task.script_ai_score = score
|
task.script_ai_score = score
|
||||||
task.script_ai_result = result
|
task.script_ai_result = result
|
||||||
task.script_ai_reviewed_at = now
|
task.script_ai_reviewed_at = now
|
||||||
|
|
||||||
|
if auto_rejected:
|
||||||
|
task.stage = TaskStage.SCRIPT_UPLOAD
|
||||||
|
else:
|
||||||
task.stage = TaskStage.SCRIPT_AGENCY_REVIEW
|
task.stage = TaskStage.SCRIPT_AGENCY_REVIEW
|
||||||
|
|
||||||
elif review_type == "video":
|
elif review_type == "video":
|
||||||
@@ -226,6 +273,10 @@ async def complete_ai_review(
|
|||||||
task.video_ai_score = score
|
task.video_ai_score = score
|
||||||
task.video_ai_result = result
|
task.video_ai_result = result
|
||||||
task.video_ai_reviewed_at = now
|
task.video_ai_reviewed_at = now
|
||||||
|
|
||||||
|
if auto_rejected:
|
||||||
|
task.stage = TaskStage.VIDEO_UPLOAD
|
||||||
|
else:
|
||||||
task.stage = TaskStage.VIDEO_AGENCY_REVIEW
|
task.stage = TaskStage.VIDEO_AGENCY_REVIEW
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -459,6 +510,7 @@ async def list_tasks_for_agency(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
stage: Optional[TaskStage] = None,
|
stage: Optional[TaskStage] = None,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
) -> Tuple[List[Task], int]:
|
) -> Tuple[List[Task], int]:
|
||||||
"""获取代理商的任务列表"""
|
"""获取代理商的任务列表"""
|
||||||
query = (
|
query = (
|
||||||
@@ -473,6 +525,8 @@ async def list_tasks_for_agency(
|
|||||||
|
|
||||||
if stage:
|
if stage:
|
||||||
query = query.where(Task.stage == stage)
|
query = query.where(Task.stage == stage)
|
||||||
|
if project_id:
|
||||||
|
query = query.where(Task.project_id == project_id)
|
||||||
|
|
||||||
query = query.order_by(Task.created_at.desc())
|
query = query.order_by(Task.created_at.desc())
|
||||||
|
|
||||||
@@ -480,6 +534,8 @@ async def list_tasks_for_agency(
|
|||||||
count_query = select(func.count(Task.id)).where(Task.agency_id == agency_id)
|
count_query = select(func.count(Task.id)).where(Task.agency_id == agency_id)
|
||||||
if stage:
|
if stage:
|
||||||
count_query = count_query.where(Task.stage == stage)
|
count_query = count_query.where(Task.stage == stage)
|
||||||
|
if project_id:
|
||||||
|
count_query = count_query.where(Task.project_id == project_id)
|
||||||
count_result = await db.execute(count_query)
|
count_result = await db.execute(count_query)
|
||||||
total = count_result.scalar() or 0
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
@@ -497,9 +553,14 @@ async def list_tasks_for_brand(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
stage: Optional[TaskStage] = None,
|
stage: Optional[TaskStage] = None,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
) -> Tuple[List[Task], int]:
|
) -> Tuple[List[Task], int]:
|
||||||
"""获取品牌方的任务列表(通过项目关联)"""
|
"""获取品牌方的任务列表(通过项目关联)"""
|
||||||
# 先获取品牌方的所有项目
|
if project_id:
|
||||||
|
# 指定了项目 ID,直接筛选该项目的任务
|
||||||
|
project_ids = [project_id]
|
||||||
|
else:
|
||||||
|
# 未指定项目,获取品牌方的所有项目
|
||||||
project_ids_query = select(Project.id).where(Project.brand_id == brand_id)
|
project_ids_query = select(Project.id).where(Project.brand_id == brand_id)
|
||||||
project_ids_result = await db.execute(project_ids_query)
|
project_ids_result = await db.execute(project_ids_query)
|
||||||
project_ids = [row[0] for row in project_ids_result.all()]
|
project_ids = [row[0] for row in project_ids_result.all()]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.review import ReviewTask, TaskStatus as DBTaskStatus
|
from app.models.review import ReviewTask, TaskStatus as DBTaskStatus
|
||||||
from app.models.rule import ForbiddenWord, Competitor
|
from app.models.rule import ForbiddenWord, Competitor, PlatformRule, RuleStatus
|
||||||
from app.models.ai_config import AIConfig
|
from app.models.ai_config import AIConfig
|
||||||
from app.services.video_download import VideoDownloadService, DownloadResult
|
from app.services.video_download import VideoDownloadService, DownloadResult
|
||||||
from app.services.keyframe import KeyFrameExtractor, ExtractionResult
|
from app.services.keyframe import KeyFrameExtractor, ExtractionResult
|
||||||
@@ -81,6 +81,7 @@ async def complete_review(
|
|||||||
summary: str,
|
summary: str,
|
||||||
violations: list[dict],
|
violations: list[dict],
|
||||||
status: DBTaskStatus = DBTaskStatus.COMPLETED,
|
status: DBTaskStatus = DBTaskStatus.COMPLETED,
|
||||||
|
soft_warnings: Optional[list[dict]] = None,
|
||||||
):
|
):
|
||||||
"""完成审核"""
|
"""完成审核"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -94,6 +95,8 @@ async def complete_review(
|
|||||||
task.score = score
|
task.score = score
|
||||||
task.summary = summary
|
task.summary = summary
|
||||||
task.violations = violations
|
task.violations = violations
|
||||||
|
if soft_warnings is not None:
|
||||||
|
task.soft_warnings = soft_warnings
|
||||||
task.completed_at = datetime.now(timezone.utc)
|
task.completed_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -153,6 +156,24 @@ async def get_competitors(db: AsyncSession, tenant_id: str, brand_id: str) -> li
|
|||||||
return [row[0] for row in result.fetchall()]
|
return [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_platform_forbidden_words(
|
||||||
|
db: AsyncSession, tenant_id: str, brand_id: str, platform: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""从 DB 获取品牌方在该平台的 active 规则中的违禁词"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlatformRule).where(
|
||||||
|
PlatformRule.tenant_id == tenant_id,
|
||||||
|
PlatformRule.brand_id == brand_id,
|
||||||
|
PlatformRule.platform == platform,
|
||||||
|
PlatformRule.status == RuleStatus.ACTIVE.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule or not rule.parsed_rules:
|
||||||
|
return []
|
||||||
|
return rule.parsed_rules.get("forbidden_words", [])
|
||||||
|
|
||||||
|
|
||||||
async def process_video_review(
|
async def process_video_review(
|
||||||
review_id: str,
|
review_id: str,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -199,6 +220,13 @@ async def process_video_review(
|
|||||||
|
|
||||||
# 获取规则
|
# 获取规则
|
||||||
forbidden_words = await get_forbidden_words(db, tenant_id)
|
forbidden_words = await get_forbidden_words(db, tenant_id)
|
||||||
|
# 合并平台规则中的违禁词
|
||||||
|
platform_fw = await get_platform_forbidden_words(db, tenant_id, brand_id, platform)
|
||||||
|
existing_set = set(forbidden_words)
|
||||||
|
for w in platform_fw:
|
||||||
|
if w not in existing_set:
|
||||||
|
forbidden_words.append(w)
|
||||||
|
existing_set.add(w)
|
||||||
competitors = await get_competitors(db, tenant_id, brand_id)
|
competitors = await get_competitors(db, tenant_id, brand_id)
|
||||||
|
|
||||||
# 初始化 AI 服务
|
# 初始化 AI 服务
|
||||||
@@ -281,16 +309,37 @@ async def process_video_review(
|
|||||||
)
|
)
|
||||||
all_violations.extend(subtitle_violations)
|
all_violations.extend(subtitle_violations)
|
||||||
|
|
||||||
# 6. 计算分数和生成报告
|
# 6. 分流 violations / soft_warnings
|
||||||
await update_review_progress(db, review_id, 90, "生成报告")
|
await update_review_progress(db, review_id, 90, "生成报告")
|
||||||
score = review_service.calculate_score(all_violations)
|
|
||||||
|
|
||||||
if not all_violations:
|
hard_violations = []
|
||||||
summary = "视频内容合规,未发现违规项"
|
soft_warnings_data = []
|
||||||
|
|
||||||
|
for v in all_violations:
|
||||||
|
v_type = v.get("type", "")
|
||||||
|
if v_type in ("forbidden_word", "efficacy_claim", "competitor_logo", "brand_safety"):
|
||||||
|
hard_violations.append(v)
|
||||||
|
elif v_type in ("duration_short", "mention_missing"):
|
||||||
|
soft_warnings_data.append({
|
||||||
|
"code": f"video_{v_type}",
|
||||||
|
"message": v.get("content", ""),
|
||||||
|
"action_required": "note",
|
||||||
|
"blocking": False,
|
||||||
|
"context": {"suggestion": v.get("suggestion", "")},
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
high_count = sum(1 for v in all_violations if v.get("risk_level") == "high")
|
hard_violations.append(v) # 默认当硬性违规
|
||||||
medium_count = sum(1 for v in all_violations if v.get("risk_level") == "medium")
|
|
||||||
summary = f"发现 {len(all_violations)} 处违规"
|
# 计算分数(仅硬性违规影响分数)
|
||||||
|
score = review_service.calculate_score(hard_violations)
|
||||||
|
|
||||||
|
if not hard_violations:
|
||||||
|
summary = "视频内容合规,未发现违规项"
|
||||||
|
if soft_warnings_data:
|
||||||
|
summary += f"({len(soft_warnings_data)} 条提醒)"
|
||||||
|
else:
|
||||||
|
high_count = sum(1 for v in hard_violations if v.get("risk_level") == "high")
|
||||||
|
summary = f"发现 {len(hard_violations)} 处违规"
|
||||||
if high_count > 0:
|
if high_count > 0:
|
||||||
summary += f"({high_count} 处高风险)"
|
summary += f"({high_count} 处高风险)"
|
||||||
|
|
||||||
@@ -300,7 +349,8 @@ async def process_video_review(
|
|||||||
review_id,
|
review_id,
|
||||||
score=score,
|
score=score,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
violations=all_violations,
|
violations=hard_violations,
|
||||||
|
soft_warnings=soft_warnings_data if soft_warnings_data else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+45
-19
@@ -6,13 +6,13 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: miaosi-postgres
|
container_name: miaosi-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: miaosi
|
POSTGRES_DB: ${POSTGRES_DB:-miaosi}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- ./data/redis:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -39,21 +39,18 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: miaosi-api
|
container_name: miaosi-api
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
DEBUG: "true"
|
env_file:
|
||||||
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app
|
|
||||||
- video_temp:/tmp/videos
|
- video_temp:/tmp/videos
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|
||||||
|
|
||||||
# Celery Worker
|
# Celery Worker
|
||||||
celery-worker:
|
celery-worker:
|
||||||
@@ -62,15 +59,16 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: miaosi-celery-worker
|
container_name: miaosi-celery-worker
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app
|
|
||||||
- video_temp:/tmp/videos
|
- video_temp:/tmp/videos
|
||||||
command: celery -A app.celery_app worker -l info -Q default,review -c 2
|
command: celery -A app.celery_app worker -l info -Q default,review -c 2
|
||||||
|
|
||||||
@@ -81,15 +79,43 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: miaosi-celery-beat
|
container_name: miaosi-celery-beat
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
- celery-worker
|
redis:
|
||||||
volumes:
|
condition: service_healthy
|
||||||
- ./app:/app/app
|
celery-worker:
|
||||||
|
condition: service_started
|
||||||
command: celery -A app.celery_app beat -l info
|
command: celery -A app.celery_app beat -l info
|
||||||
|
|
||||||
|
# Next.js 前端
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ../frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-https://your-domain.com}
|
||||||
|
NEXT_PUBLIC_USE_MOCK: "false"
|
||||||
|
container_name: miaosi-frontend
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
# Nginx 反向代理
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: miaosi-nginx
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
- frontend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
|
||||||
redis_data:
|
|
||||||
video_temp:
|
video_temp:
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
# SSE 代理(长连接,必须在 /api/ 之前匹配)
|
||||||
|
location /api/v1/sse/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
chunked_transfer_encoding off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 前端代理
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
# Gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
|
||||||
|
# 安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
# 上传大小限制(与后端 MAX_FILE_SIZE_MB 对齐)
|
||||||
|
client_max_body_size 500m;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
@@ -14,12 +14,19 @@ dependencies = [
|
|||||||
"httpx>=0.26.0",
|
"httpx>=0.26.0",
|
||||||
"pydantic[email]>=2.5.0",
|
"pydantic[email]>=2.5.0",
|
||||||
"pydantic-settings>=2.0.0",
|
"pydantic-settings>=2.0.0",
|
||||||
"python-jose>=3.3.0",
|
"PyJWT>=2.8.0",
|
||||||
"passlib>=1.7.4",
|
"passlib>=1.7.4",
|
||||||
"alembic>=1.13.0",
|
"alembic>=1.13.0",
|
||||||
"cryptography>=42.0.0",
|
"cryptography>=42.0.0",
|
||||||
"openai>=1.12.0",
|
"openai>=1.12.0",
|
||||||
"cachetools>=5.3.0",
|
"cachetools>=5.3.0",
|
||||||
|
"sse-starlette>=2.0.0",
|
||||||
|
"pdfplumber>=0.10.0",
|
||||||
|
"python-docx>=1.1.0",
|
||||||
|
"openpyxl>=3.1.0",
|
||||||
|
"PyMuPDF>=1.24.0",
|
||||||
|
"tos>=2.7.0",
|
||||||
|
"socksio>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -56,6 +63,7 @@ markers = [
|
|||||||
]
|
]
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
"ignore::DeprecationWarning",
|
"ignore::DeprecationWarning",
|
||||||
|
"ignore::jwt.warnings.InsecureKeyLengthWarning",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
|
|||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ===========================
|
||||||
|
# PostgreSQL 每日备份脚本
|
||||||
|
# 备份到本地 + 上传到火山引擎 TOS
|
||||||
|
# ===========================
|
||||||
|
# 配合 crontab 使用:
|
||||||
|
# 0 3 * * * /path/to/backup.sh >> /var/log/miaosi-backup.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---- 配置 ----
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/miaosi}"
|
||||||
|
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-miaosi-postgres}"
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-miaosi}"
|
||||||
|
RETAIN_DAYS="${RETAIN_DAYS:-7}"
|
||||||
|
|
||||||
|
# TOS 备份桶(需要先安装 tosutil 并配置好凭证)
|
||||||
|
TOS_BACKUP_BUCKET="${TOS_BACKUP_BUCKET:-}"
|
||||||
|
TOS_BACKUP_PREFIX="${TOS_BACKUP_PREFIX:-backups/postgres}"
|
||||||
|
|
||||||
|
# ---- 执行 ----
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
FILENAME="miaosi_${DATE}.sql.gz"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
echo "[$(date)] 开始备份数据库 ${POSTGRES_DB}..."
|
||||||
|
|
||||||
|
# 1. pg_dump 导出并压缩
|
||||||
|
docker exec "$POSTGRES_CONTAINER" pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > "${BACKUP_DIR}/${FILENAME}"
|
||||||
|
|
||||||
|
echo "[$(date)] 本地备份完成: ${BACKUP_DIR}/${FILENAME}"
|
||||||
|
|
||||||
|
# 2. 上传到 TOS(如果配置了备份桶)
|
||||||
|
if [ -n "$TOS_BACKUP_BUCKET" ]; then
|
||||||
|
if command -v tosutil &> /dev/null; then
|
||||||
|
tosutil cp "${BACKUP_DIR}/${FILENAME}" "tos://${TOS_BACKUP_BUCKET}/${TOS_BACKUP_PREFIX}/${FILENAME}"
|
||||||
|
echo "[$(date)] 已上传到 TOS: ${TOS_BACKUP_BUCKET}/${TOS_BACKUP_PREFIX}/${FILENAME}"
|
||||||
|
else
|
||||||
|
echo "[$(date)] 警告: tosutil 未安装,跳过 TOS 上传"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. 清理过期的本地备份
|
||||||
|
find "$BACKUP_DIR" -name "miaosi_*.sql.gz" -mtime +"$RETAIN_DAYS" -delete
|
||||||
|
echo "[$(date)] 已清理 ${RETAIN_DAYS} 天前的本地备份"
|
||||||
|
|
||||||
|
echo "[$(date)] 备份完成"
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Docker 容器入口脚本
|
||||||
|
# 先初始化数据库,再启动应用
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== 秒思智能审核平台 - 启动中 ==="
|
||||||
|
|
||||||
|
# 运行数据库迁移
|
||||||
|
echo "运行数据库迁移..."
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# 填充种子数据
|
||||||
|
echo "填充种子数据..."
|
||||||
|
python -m scripts.seed
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
echo "启动应用..."
|
||||||
|
exec "$@"
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 数据库初始化脚本
|
||||||
|
# 运行 Alembic 迁移 + 填充种子数据
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== 数据库初始化 ==="
|
||||||
|
|
||||||
|
echo "1. 运行 Alembic 迁移..."
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
echo "2. 填充种子数据..."
|
||||||
|
python -m scripts.seed
|
||||||
|
|
||||||
|
echo "=== 数据库初始化完成 ==="
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
"""
|
||||||
|
种子数据脚本
|
||||||
|
创建 demo 用户、组织关系、项目、Brief、任务、规则数据
|
||||||
|
支持幂等运行:已存在则跳过
|
||||||
|
|
||||||
|
用法:
|
||||||
|
cd backend && python -m scripts.seed
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select, insert, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
# 确保能找到 app 模块
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models import (
|
||||||
|
User, UserRole, Brand, Agency, Creator,
|
||||||
|
Project, Task, TaskStage, TaskStatus, Brief,
|
||||||
|
ForbiddenWord, WhitelistItem, Competitor, AIConfig, Tenant,
|
||||||
|
Message,
|
||||||
|
brand_agency_association, agency_creator_association,
|
||||||
|
project_agency_association,
|
||||||
|
)
|
||||||
|
from app.services.auth import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 固定 ID,方便前端 mock 数据对齐和反复运行幂等检查
|
||||||
|
# ============================================================
|
||||||
|
BRAND_USER_ID = "U100001"
|
||||||
|
AGENCY_USER_ID = "U100002"
|
||||||
|
CREATOR_USER_ID = "U100003"
|
||||||
|
|
||||||
|
BRAND_ID = "BR100001"
|
||||||
|
AGENCY_ID = "AG100001"
|
||||||
|
CREATOR_ID = "CR100001"
|
||||||
|
|
||||||
|
TENANT_ID = BRAND_ID # 品牌方 = 租户
|
||||||
|
|
||||||
|
PROJECT_ID = "PJ100001"
|
||||||
|
BRIEF_ID = "BF100001"
|
||||||
|
|
||||||
|
TASK_IDS = ["TK100001", "TK100002", "TK100003", "TK100004"]
|
||||||
|
|
||||||
|
PASSWORD_HASH = hash_password("demo123")
|
||||||
|
|
||||||
|
NOW = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_data() -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# ========== 幂等检查 ==========
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.email == "brand@demo.com")
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
print("✅ 种子数据已存在,跳过创建")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("🌱 开始创建种子数据...")
|
||||||
|
|
||||||
|
# ========== 1. Demo 用户 ==========
|
||||||
|
brand_user = User(
|
||||||
|
id=BRAND_USER_ID,
|
||||||
|
email="brand@demo.com",
|
||||||
|
password_hash=PASSWORD_HASH,
|
||||||
|
name="秒思科技",
|
||||||
|
role=UserRole.BRAND,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
agency_user = User(
|
||||||
|
id=AGENCY_USER_ID,
|
||||||
|
email="agency@demo.com",
|
||||||
|
password_hash=PASSWORD_HASH,
|
||||||
|
name="星辰传媒",
|
||||||
|
role=UserRole.AGENCY,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
creator_user = User(
|
||||||
|
id=CREATOR_USER_ID,
|
||||||
|
email="creator@demo.com",
|
||||||
|
password_hash=PASSWORD_HASH,
|
||||||
|
name="李小红",
|
||||||
|
role=UserRole.CREATOR,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add_all([brand_user, agency_user, creator_user])
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 用户已创建: brand@demo.com / agency@demo.com / creator@demo.com")
|
||||||
|
|
||||||
|
# ========== 2. 组织实体 ==========
|
||||||
|
brand = Brand(
|
||||||
|
id=BRAND_ID,
|
||||||
|
user_id=BRAND_USER_ID,
|
||||||
|
name="秒思科技",
|
||||||
|
description="秒思科技是一家专注于 AI 内容合规的科技公司",
|
||||||
|
contact_name="张经理",
|
||||||
|
contact_phone="13800138000",
|
||||||
|
contact_email="brand@demo.com",
|
||||||
|
final_review_enabled=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
agency = Agency(
|
||||||
|
id=AGENCY_ID,
|
||||||
|
user_id=AGENCY_USER_ID,
|
||||||
|
name="星辰传媒",
|
||||||
|
description="星辰传媒是一家专业的内容营销代理商",
|
||||||
|
contact_name="王总监",
|
||||||
|
contact_phone="13900139000",
|
||||||
|
contact_email="agency@demo.com",
|
||||||
|
force_pass_enabled=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
creator = Creator(
|
||||||
|
id=CREATOR_ID,
|
||||||
|
user_id=CREATOR_USER_ID,
|
||||||
|
name="李小红",
|
||||||
|
bio="美妆博主,专注护肤分享,全网粉丝 50 万+",
|
||||||
|
douyin_account="lixiaohong_dy",
|
||||||
|
xiaohongshu_account="lixiaohong_xhs",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([brand, agency, creator])
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 组织已创建: 秒思科技 / 星辰传媒 / 李小红")
|
||||||
|
|
||||||
|
# ========== 3. 租户(兼容旧表) ==========
|
||||||
|
tenant = Tenant(
|
||||||
|
id=TENANT_ID,
|
||||||
|
name="秒思科技",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(tenant)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 租户已创建: 秒思科技")
|
||||||
|
|
||||||
|
# ========== 4. 组织关联关系 ==========
|
||||||
|
await db.execute(
|
||||||
|
insert(brand_agency_association).values(
|
||||||
|
brand_id=BRAND_ID,
|
||||||
|
agency_id=AGENCY_ID,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
insert(agency_creator_association).values(
|
||||||
|
agency_id=AGENCY_ID,
|
||||||
|
creator_id=CREATOR_ID,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 组织关系已建立: 品牌方 → 代理商 → 达人")
|
||||||
|
|
||||||
|
# ========== 5. 项目 ==========
|
||||||
|
project = Project(
|
||||||
|
id=PROJECT_ID,
|
||||||
|
brand_id=BRAND_ID,
|
||||||
|
name="2026春季新品推广",
|
||||||
|
description="春季新品防晒霜推广活动,面向 18-35 岁女性用户,重点投放抖音和小红书平台",
|
||||||
|
platform="douyin",
|
||||||
|
start_date=NOW,
|
||||||
|
deadline=NOW + timedelta(days=30),
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 项目 → 代理商关联
|
||||||
|
await db.execute(
|
||||||
|
insert(project_agency_association).values(
|
||||||
|
project_id=PROJECT_ID,
|
||||||
|
agency_id=AGENCY_ID,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 项目已创建: 2026春季新品推广")
|
||||||
|
|
||||||
|
# ========== 6. Brief ==========
|
||||||
|
brief = Brief(
|
||||||
|
id=BRIEF_ID,
|
||||||
|
project_id=PROJECT_ID,
|
||||||
|
selling_points=[
|
||||||
|
{"content": "SPF50+ PA++++,超强防晒", "priority": "core"},
|
||||||
|
{"content": "轻薄不油腻,适合日常通勤", "priority": "core"},
|
||||||
|
{"content": "添加玻尿酸成分,防晒同时保湿", "priority": "recommended"},
|
||||||
|
{"content": "获得皮肤科医生推荐", "priority": "reference"},
|
||||||
|
],
|
||||||
|
blacklist_words=[
|
||||||
|
{"word": "最好", "reason": "绝对化用语"},
|
||||||
|
{"word": "第一", "reason": "绝对化用语"},
|
||||||
|
{"word": "纯天然", "reason": "虚假宣传"},
|
||||||
|
],
|
||||||
|
competitors=["安耐晒", "怡思丁", "薇诺娜"],
|
||||||
|
brand_tone="年轻、活力、专业、可信赖",
|
||||||
|
min_selling_points=2,
|
||||||
|
min_duration=30,
|
||||||
|
max_duration=60,
|
||||||
|
other_requirements="请在视频中展示产品实际使用效果,包含户外场景拍摄",
|
||||||
|
)
|
||||||
|
db.add(brief)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ Brief 已创建")
|
||||||
|
|
||||||
|
# ========== 7. 示例任务(4 种阶段) ==========
|
||||||
|
tasks = [
|
||||||
|
# TK-001: 等待上传脚本
|
||||||
|
Task(
|
||||||
|
id=TASK_IDS[0],
|
||||||
|
project_id=PROJECT_ID,
|
||||||
|
agency_id=AGENCY_ID,
|
||||||
|
creator_id=CREATOR_ID,
|
||||||
|
name="春季防晒霜种草视频(1)",
|
||||||
|
sequence=1,
|
||||||
|
stage=TaskStage.SCRIPT_UPLOAD,
|
||||||
|
),
|
||||||
|
# TK-002: 脚本等待代理商审核
|
||||||
|
Task(
|
||||||
|
id=TASK_IDS[1],
|
||||||
|
project_id=PROJECT_ID,
|
||||||
|
agency_id=AGENCY_ID,
|
||||||
|
creator_id=CREATOR_ID,
|
||||||
|
name="春季防晒霜种草视频(2)",
|
||||||
|
sequence=2,
|
||||||
|
stage=TaskStage.SCRIPT_AGENCY_REVIEW,
|
||||||
|
script_file_url="https://example.com/scripts/demo-script.pdf",
|
||||||
|
script_file_name="防晒霜种草脚本v2.pdf",
|
||||||
|
script_uploaded_at=NOW - timedelta(hours=2),
|
||||||
|
script_ai_score=85,
|
||||||
|
script_ai_result={
|
||||||
|
"score": 85,
|
||||||
|
"summary": "脚本整体符合要求,卖点覆盖充分",
|
||||||
|
"dimensions": {
|
||||||
|
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"platform": {"score": 85, "passed": True, "issue_count": 1},
|
||||||
|
"brand_safety": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"brief_match": {"score": 80, "passed": True, "issue_count": 1},
|
||||||
|
},
|
||||||
|
"selling_point_matches": [
|
||||||
|
{"content": "SPF50+ PA++++,超强防晒", "priority": "core", "matched": True, "evidence": "脚本中提到了SPF50+防晒参数"},
|
||||||
|
{"content": "轻薄不油腻,适合日常通勤", "priority": "core", "matched": True, "evidence": "提到了轻薄质地不油腻"},
|
||||||
|
{"content": "添加玻尿酸成分,防晒同时保湿", "priority": "recommended", "matched": False, "evidence": "未提及玻尿酸成分"},
|
||||||
|
],
|
||||||
|
"brief_match_detail": {
|
||||||
|
"total_points": 3,
|
||||||
|
"matched_points": 2,
|
||||||
|
"required_points": 2,
|
||||||
|
"coverage_score": 100,
|
||||||
|
"overall_score": 75,
|
||||||
|
"highlights": [
|
||||||
|
"防晒参数描述准确,SPF50+ PA++++完整提及",
|
||||||
|
"产品使用场景贴合Brief要求的日常通勤场景",
|
||||||
|
],
|
||||||
|
"issues": [
|
||||||
|
"缺少玻尿酸保湿成分的说明,建议补充产品成分亮点",
|
||||||
|
"脚本中使用了\"神器\"等夸张用语,需替换为更客观的表述",
|
||||||
|
],
|
||||||
|
"explanation": "脚本覆盖了2/2条要求卖点,核心卖点全部匹配。整体内容方向正确,但部分细节可优化。",
|
||||||
|
},
|
||||||
|
"violations": [
|
||||||
|
{"type": "forbidden_word", "content": "神器", "severity": "medium", "suggestion": "建议替换为\"好物\"", "dimension": "platform"},
|
||||||
|
],
|
||||||
|
"soft_warnings": [
|
||||||
|
{"type": "suggestion", "content": "建议增加产品成分说明", "suggestion": "可提及玻尿酸等核心成分"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
script_ai_reviewed_at=NOW - timedelta(hours=1),
|
||||||
|
),
|
||||||
|
# TK-003: 脚本已通过,等待上传视频
|
||||||
|
Task(
|
||||||
|
id=TASK_IDS[2],
|
||||||
|
project_id=PROJECT_ID,
|
||||||
|
agency_id=AGENCY_ID,
|
||||||
|
creator_id=CREATOR_ID,
|
||||||
|
name="春季防晒霜种草视频(3)",
|
||||||
|
sequence=3,
|
||||||
|
stage=TaskStage.VIDEO_UPLOAD,
|
||||||
|
script_file_url="https://example.com/scripts/demo-script-3.pdf",
|
||||||
|
script_file_name="防晒霜种草脚本v3.pdf",
|
||||||
|
script_uploaded_at=NOW - timedelta(days=2),
|
||||||
|
script_ai_score=92,
|
||||||
|
script_ai_result={
|
||||||
|
"score": 92,
|
||||||
|
"summary": "脚本质量优秀,完全符合 Brief 要求",
|
||||||
|
"dimensions": {
|
||||||
|
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"platform": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"brand_safety": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"brief_match": {"score": 90, "passed": True, "issue_count": 0},
|
||||||
|
},
|
||||||
|
"selling_point_matches": [
|
||||||
|
{"content": "SPF50+ PA++++,超强防晒", "priority": "core", "matched": True, "evidence": "脚本完整提及防晒参数"},
|
||||||
|
{"content": "轻薄不油腻,适合日常通勤", "priority": "core", "matched": True, "evidence": "详细描述了质地体验"},
|
||||||
|
{"content": "添加玻尿酸成分,防晒同时保湿", "priority": "recommended", "matched": True, "evidence": "提及了玻尿酸保湿功能"},
|
||||||
|
],
|
||||||
|
"brief_match_detail": {
|
||||||
|
"total_points": 3,
|
||||||
|
"matched_points": 3,
|
||||||
|
"required_points": 2,
|
||||||
|
"coverage_score": 100,
|
||||||
|
"overall_score": 90,
|
||||||
|
"highlights": [
|
||||||
|
"所有核心和推荐卖点均完整覆盖",
|
||||||
|
"产品使用场景自然,与Brief要求高度一致",
|
||||||
|
"成分说明准确,玻尿酸保湿功能表述清晰",
|
||||||
|
],
|
||||||
|
"issues": [],
|
||||||
|
"explanation": "脚本覆盖了3/2条要求卖点(超出要求),与Brief整体匹配度优秀。",
|
||||||
|
},
|
||||||
|
"violations": [],
|
||||||
|
"soft_warnings": [],
|
||||||
|
},
|
||||||
|
script_ai_reviewed_at=NOW - timedelta(days=2),
|
||||||
|
script_agency_status=TaskStatus.PASSED,
|
||||||
|
script_agency_comment="脚本内容不错,可以进入拍摄",
|
||||||
|
script_agency_reviewer_id=AGENCY_USER_ID,
|
||||||
|
script_agency_reviewed_at=NOW - timedelta(days=1),
|
||||||
|
script_brand_status=TaskStatus.PASSED,
|
||||||
|
script_brand_comment="同意",
|
||||||
|
script_brand_reviewer_id=BRAND_USER_ID,
|
||||||
|
script_brand_reviewed_at=NOW - timedelta(days=1),
|
||||||
|
),
|
||||||
|
# TK-004: 已完成
|
||||||
|
Task(
|
||||||
|
id=TASK_IDS[3],
|
||||||
|
project_id=PROJECT_ID,
|
||||||
|
agency_id=AGENCY_ID,
|
||||||
|
creator_id=CREATOR_ID,
|
||||||
|
name="春季防晒霜种草视频(4)",
|
||||||
|
sequence=4,
|
||||||
|
stage=TaskStage.COMPLETED,
|
||||||
|
script_file_url="https://example.com/scripts/demo-script-4.pdf",
|
||||||
|
script_file_name="防晒霜种草脚本v4.pdf",
|
||||||
|
script_uploaded_at=NOW - timedelta(days=7),
|
||||||
|
script_ai_score=90,
|
||||||
|
script_ai_result={
|
||||||
|
"score": 90,
|
||||||
|
"summary": "符合要求",
|
||||||
|
"dimensions": {
|
||||||
|
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"platform": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"brand_safety": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"brief_match": {"score": 85, "passed": True, "issue_count": 0},
|
||||||
|
},
|
||||||
|
"selling_point_matches": [],
|
||||||
|
"violations": [],
|
||||||
|
"soft_warnings": [],
|
||||||
|
},
|
||||||
|
script_ai_reviewed_at=NOW - timedelta(days=7),
|
||||||
|
script_agency_status=TaskStatus.PASSED,
|
||||||
|
script_agency_comment="通过",
|
||||||
|
script_agency_reviewer_id=AGENCY_USER_ID,
|
||||||
|
script_agency_reviewed_at=NOW - timedelta(days=6),
|
||||||
|
script_brand_status=TaskStatus.PASSED,
|
||||||
|
script_brand_comment="通过",
|
||||||
|
script_brand_reviewer_id=BRAND_USER_ID,
|
||||||
|
script_brand_reviewed_at=NOW - timedelta(days=6),
|
||||||
|
video_file_url="https://example.com/videos/demo-video-4.mp4",
|
||||||
|
video_file_name="防晒霜种草视频v4.mp4",
|
||||||
|
video_duration=45,
|
||||||
|
video_uploaded_at=NOW - timedelta(days=5),
|
||||||
|
video_ai_score=88,
|
||||||
|
video_ai_result={
|
||||||
|
"score": 88,
|
||||||
|
"summary": "视频质量良好",
|
||||||
|
"dimensions": {
|
||||||
|
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"platform": {"score": 100, "passed": True, "issue_count": 0},
|
||||||
|
"brand_safety": {"score": 85, "passed": True, "issue_count": 0},
|
||||||
|
"brief_match": {"score": 80, "passed": True, "issue_count": 0},
|
||||||
|
},
|
||||||
|
"selling_point_matches": [],
|
||||||
|
"violations": [],
|
||||||
|
"soft_warnings": [],
|
||||||
|
},
|
||||||
|
video_ai_reviewed_at=NOW - timedelta(days=5),
|
||||||
|
video_agency_status=TaskStatus.PASSED,
|
||||||
|
video_agency_comment="视频效果好",
|
||||||
|
video_agency_reviewer_id=AGENCY_USER_ID,
|
||||||
|
video_agency_reviewed_at=NOW - timedelta(days=4),
|
||||||
|
video_brand_status=TaskStatus.PASSED,
|
||||||
|
video_brand_comment="终审通过",
|
||||||
|
video_brand_reviewer_id=BRAND_USER_ID,
|
||||||
|
video_brand_reviewed_at=NOW - timedelta(days=3),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db.add_all(tasks)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 任务已创建: TK100001~TK100004 (4种阶段)")
|
||||||
|
|
||||||
|
# ========== 8. 规则数据 ==========
|
||||||
|
forbidden_words = [
|
||||||
|
ForbiddenWord(id="FW100001", tenant_id=TENANT_ID, word="假药", category="法规违禁", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100002", tenant_id=TENANT_ID, word="虚假宣传", category="法规违禁", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100003", tenant_id=TENANT_ID, word="最好", category="绝对化用语", severity="medium"),
|
||||||
|
ForbiddenWord(id="FW100004", tenant_id=TENANT_ID, word="第一", category="绝对化用语", severity="medium"),
|
||||||
|
ForbiddenWord(id="FW100005", tenant_id=TENANT_ID, word="纯天然", category="虚假宣传", severity="medium"),
|
||||||
|
# 功效词(品牌方可自行增删)
|
||||||
|
ForbiddenWord(id="FW100006", tenant_id=TENANT_ID, word="根治", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100007", tenant_id=TENANT_ID, word="治愈", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100008", tenant_id=TENANT_ID, word="治疗", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100009", tenant_id=TENANT_ID, word="药效", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100010", tenant_id=TENANT_ID, word="疗效", category="功效词", severity="high"),
|
||||||
|
ForbiddenWord(id="FW100011", tenant_id=TENANT_ID, word="特效", category="功效词", severity="high"),
|
||||||
|
]
|
||||||
|
db.add_all(forbidden_words)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 违禁词已创建: 11 条(含 6 条功效词)")
|
||||||
|
|
||||||
|
competitors = [
|
||||||
|
Competitor(id="CP100001", tenant_id=TENANT_ID, brand_id=BRAND_ID, name="安耐晒", keywords=["安耐晒", "ANESSA", "资生堂防晒"]),
|
||||||
|
Competitor(id="CP100002", tenant_id=TENANT_ID, brand_id=BRAND_ID, name="怡思丁", keywords=["怡思丁", "ISDIN"]),
|
||||||
|
Competitor(id="CP100003", tenant_id=TENANT_ID, brand_id=BRAND_ID, name="薇诺娜", keywords=["薇诺娜", "WINONA"]),
|
||||||
|
]
|
||||||
|
db.add_all(competitors)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 竞品已创建: 3 条")
|
||||||
|
|
||||||
|
whitelist_items = [
|
||||||
|
WhitelistItem(id="WL100001", tenant_id=TENANT_ID, brand_id=BRAND_ID, term="SPF50+", reason="产品实际参数,非夸大宣传"),
|
||||||
|
WhitelistItem(id="WL100002", tenant_id=TENANT_ID, brand_id=BRAND_ID, term="PA++++", reason="产品实际参数,非夸大宣传"),
|
||||||
|
]
|
||||||
|
db.add_all(whitelist_items)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 白名单已创建: 2 条")
|
||||||
|
|
||||||
|
# ========== 9. AI 配置(模板) ==========
|
||||||
|
ai_config = AIConfig(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
provider="oneapi",
|
||||||
|
base_url="https://api.example.com/v1",
|
||||||
|
api_key_encrypted="demo-placeholder-key",
|
||||||
|
models={"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=2000,
|
||||||
|
is_configured=False,
|
||||||
|
)
|
||||||
|
db.add(ai_config)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ AI 配置模板已创建")
|
||||||
|
|
||||||
|
# ========== 10. 示例消息 ==========
|
||||||
|
messages = [
|
||||||
|
# 达人消息
|
||||||
|
Message(
|
||||||
|
id="MSG100001",
|
||||||
|
user_id=CREATOR_USER_ID,
|
||||||
|
type="new_task",
|
||||||
|
title="新任务分配",
|
||||||
|
content="您有新的任务「春季防晒霜种草视频(1)」,来自项目「2026春季新品推广」",
|
||||||
|
is_read=False,
|
||||||
|
related_task_id=TASK_IDS[0],
|
||||||
|
related_project_id=PROJECT_ID,
|
||||||
|
sender_name="星辰传媒",
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id="MSG100002",
|
||||||
|
user_id=CREATOR_USER_ID,
|
||||||
|
type="pass",
|
||||||
|
title="脚本审核通过",
|
||||||
|
content="您的任务「春季防晒霜种草视频(3)」脚本已被通过",
|
||||||
|
is_read=True,
|
||||||
|
related_task_id=TASK_IDS[2],
|
||||||
|
sender_name="星辰传媒",
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id="MSG100003",
|
||||||
|
user_id=CREATOR_USER_ID,
|
||||||
|
type="system_notice",
|
||||||
|
title="系统通知",
|
||||||
|
content="平台违禁词库已更新,请在创作时注意避免使用新增的违禁词",
|
||||||
|
is_read=True,
|
||||||
|
),
|
||||||
|
# 代理商消息
|
||||||
|
Message(
|
||||||
|
id="MSG100004",
|
||||||
|
user_id=AGENCY_USER_ID,
|
||||||
|
type="new_task",
|
||||||
|
title="新脚本提交",
|
||||||
|
content="达人「李小红」提交了「春季防晒霜种草视频(2)」脚本,请及时审核",
|
||||||
|
is_read=False,
|
||||||
|
related_task_id=TASK_IDS[1],
|
||||||
|
sender_name="李小红",
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id="MSG100005",
|
||||||
|
user_id=AGENCY_USER_ID,
|
||||||
|
type="pass",
|
||||||
|
title="品牌终审通过",
|
||||||
|
content="任务「春季防晒霜种草视频(4)」已通过品牌方终审",
|
||||||
|
is_read=True,
|
||||||
|
related_task_id=TASK_IDS[3],
|
||||||
|
sender_name="秒思科技",
|
||||||
|
),
|
||||||
|
# 品牌方消息
|
||||||
|
Message(
|
||||||
|
id="MSG100006",
|
||||||
|
user_id=BRAND_USER_ID,
|
||||||
|
type="new_task",
|
||||||
|
title="脚本待终审",
|
||||||
|
content="「星辰传媒」的达人「李小红」脚本已通过代理商审核,请进行终审",
|
||||||
|
is_read=False,
|
||||||
|
related_task_id=TASK_IDS[1],
|
||||||
|
sender_name="星辰传媒",
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id="MSG100007",
|
||||||
|
user_id=BRAND_USER_ID,
|
||||||
|
type="system_notice",
|
||||||
|
title="项目创建成功",
|
||||||
|
content="您的项目「2026春季新品推广」已创建成功",
|
||||||
|
is_read=True,
|
||||||
|
related_project_id=PROJECT_ID,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db.add_all(messages)
|
||||||
|
await db.flush()
|
||||||
|
print(" ✓ 示例消息已创建: 7 条 (达人3 + 代理商2 + 品牌方2)")
|
||||||
|
|
||||||
|
# ========== 提交 ==========
|
||||||
|
await db.commit()
|
||||||
|
print("\n🎉 种子数据创建完成!")
|
||||||
|
print("=" * 50)
|
||||||
|
print("Demo 账号:")
|
||||||
|
print(" 品牌方: brand@demo.com / demo123")
|
||||||
|
print(" 代理商: agency@demo.com / demo123")
|
||||||
|
print(" 达人: creator@demo.com / demo123")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
asyncio.run(seed_data())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -23,6 +23,10 @@ sleep 5
|
|||||||
echo "运行数据库迁移..."
|
echo "运行数据库迁移..."
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
|
# 填充种子数据
|
||||||
|
echo "填充种子数据..."
|
||||||
|
python3 -m scripts.seed
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== 基础服务已启动 ==="
|
echo "=== 基础服务已启动 ==="
|
||||||
echo "PostgreSQL: localhost:5432"
|
echo "PostgreSQL: localhost:5432"
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
消息 API 测试
|
||||||
|
覆盖: GET /messages, GET /messages/unread-count, PUT /messages/{id}/read, PUT /messages/read-all
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.message_service import create_message
|
||||||
|
|
||||||
|
|
||||||
|
MESSAGES_URL = "/api/v1/messages"
|
||||||
|
REGISTER_URL = "/api/v1/auth/register"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _register(client: AsyncClient, role: str, name: str, email: str) -> dict:
|
||||||
|
resp = await client.post(REGISTER_URL, json={
|
||||||
|
"email": email,
|
||||||
|
"password": "Test1234!",
|
||||||
|
"name": name,
|
||||||
|
"role": role,
|
||||||
|
"email_code": "000000",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_messages(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
count: int = 5,
|
||||||
|
msg_type: str = "system",
|
||||||
|
is_read: bool = False,
|
||||||
|
) -> list:
|
||||||
|
"""在数据库中直接创建消息(绕过 API)"""
|
||||||
|
msgs = []
|
||||||
|
for i in range(count):
|
||||||
|
m = await create_message(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
type=msg_type,
|
||||||
|
title=f"测试消息 {i+1}",
|
||||||
|
content=f"消息内容 {i+1}",
|
||||||
|
related_task_id=f"TK{100000+i}",
|
||||||
|
sender_name="系统",
|
||||||
|
)
|
||||||
|
if is_read:
|
||||||
|
m.is_read = True
|
||||||
|
msgs.append(m)
|
||||||
|
await db.commit()
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== GET /messages ====================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetMessages:
|
||||||
|
"""消息列表"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_messages(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "空消息", "empty-msg@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.get(MESSAGES_URL, headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
assert body["items"] == []
|
||||||
|
assert body["total"] == 0
|
||||||
|
assert body["page"] == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_messages(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
data = await _register(client, "brand", "消息列表", "list-msg@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
user_id = data["user"]["id"]
|
||||||
|
|
||||||
|
await _seed_messages(test_db_session, user_id, count=3)
|
||||||
|
|
||||||
|
resp = await client.get(MESSAGES_URL, headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total"] == 3
|
||||||
|
assert len(body["items"]) == 3
|
||||||
|
|
||||||
|
# 验证消息结构
|
||||||
|
msg = body["items"][0]
|
||||||
|
assert "id" in msg
|
||||||
|
assert "type" in msg
|
||||||
|
assert "title" in msg
|
||||||
|
assert "content" in msg
|
||||||
|
assert "is_read" in msg
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pagination(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
data = await _register(client, "brand", "分页测试", "page-msg@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
user_id = data["user"]["id"]
|
||||||
|
|
||||||
|
await _seed_messages(test_db_session, user_id, count=15)
|
||||||
|
|
||||||
|
# 第 1 页
|
||||||
|
resp1 = await client.get(
|
||||||
|
MESSAGES_URL, params={"page": 1, "page_size": 10}, headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
body1 = resp1.json()
|
||||||
|
assert len(body1["items"]) == 10
|
||||||
|
assert body1["total"] == 15
|
||||||
|
assert body1["page"] == 1
|
||||||
|
|
||||||
|
# 第 2 页
|
||||||
|
resp2 = await client.get(
|
||||||
|
MESSAGES_URL, params={"page": 2, "page_size": 10}, headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
body2 = resp2.json()
|
||||||
|
assert len(body2["items"]) == 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_by_read_status(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
data = await _register(client, "brand", "已读过滤", "read-filter@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
user_id = data["user"]["id"]
|
||||||
|
|
||||||
|
await _seed_messages(test_db_session, user_id, count=3, is_read=False)
|
||||||
|
await _seed_messages(test_db_session, user_id, count=2, is_read=True)
|
||||||
|
|
||||||
|
# 只看未读
|
||||||
|
resp = await client.get(
|
||||||
|
MESSAGES_URL, params={"is_read": False}, headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total"] == 3
|
||||||
|
for m in body["items"]:
|
||||||
|
assert m["is_read"] is False
|
||||||
|
|
||||||
|
# 只看已读
|
||||||
|
resp2 = await client.get(
|
||||||
|
MESSAGES_URL, params={"is_read": True}, headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["total"] == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_filter_by_type(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
data = await _register(client, "brand", "类型过滤", "type-filter@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
user_id = data["user"]["id"]
|
||||||
|
|
||||||
|
await _seed_messages(test_db_session, user_id, count=3, msg_type="new_task")
|
||||||
|
await _seed_messages(test_db_session, user_id, count=2, msg_type="system")
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
MESSAGES_URL, params={"type": "new_task"}, headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total"] == 3
|
||||||
|
for m in body["items"]:
|
||||||
|
assert m["type"] == "new_task"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_messages_isolation(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
"""用户只能看到自己的消息"""
|
||||||
|
data_a = await _register(client, "brand", "用户A", "user-a@test.com")
|
||||||
|
data_b = await _register(client, "agency", "用户B", "user-b@test.com")
|
||||||
|
|
||||||
|
await _seed_messages(test_db_session, data_a["user"]["id"], count=3)
|
||||||
|
await _seed_messages(test_db_session, data_b["user"]["id"], count=5)
|
||||||
|
|
||||||
|
resp = await client.get(MESSAGES_URL, headers=_auth(data_a["access_token"]))
|
||||||
|
assert resp.json()["total"] == 3
|
||||||
|
|
||||||
|
resp2 = await client.get(MESSAGES_URL, headers=_auth(data_b["access_token"]))
|
||||||
|
assert resp2.json()["total"] == 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_messages_unauthenticated(self, client: AsyncClient):
|
||||||
|
resp = await client.get(MESSAGES_URL)
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== GET /messages/unread-count ====================
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnreadCount:
|
||||||
|
"""未读消息数"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unread_count_zero(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "零未读", "zero-unread@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.get(f"{MESSAGES_URL}/unread-count", headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["count"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unread_count(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
data = await _register(client, "brand", "未读计数", "unread-count@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
user_id = data["user"]["id"]
|
||||||
|
|
||||||
|
await _seed_messages(test_db_session, user_id, count=5, is_read=False)
|
||||||
|
await _seed_messages(test_db_session, user_id, count=3, is_read=True)
|
||||||
|
|
||||||
|
resp = await client.get(f"{MESSAGES_URL}/unread-count", headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["count"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== PUT /messages/{id}/read ====================
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkAsRead:
|
||||||
|
"""标记单条消息已读"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_as_read(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
data = await _register(client, "brand", "标记已读", "mark-read@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
user_id = data["user"]["id"]
|
||||||
|
|
||||||
|
msgs = await _seed_messages(test_db_session, user_id, count=1)
|
||||||
|
msg_id = msgs[0].id
|
||||||
|
|
||||||
|
resp = await client.put(f"{MESSAGES_URL}/{msg_id}/read", headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# 验证未读数减少
|
||||||
|
count_resp = await client.get(f"{MESSAGES_URL}/unread-count", headers=_auth(token))
|
||||||
|
assert count_resp.json()["count"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_nonexistent_message(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "不存在", "nonexist-msg@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(f"{MESSAGES_URL}/MSG999999/read", headers=_auth(token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_other_users_message(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
"""不能标记别人的消息"""
|
||||||
|
data_a = await _register(client, "brand", "用户A标记", "mark-a@test.com")
|
||||||
|
data_b = await _register(client, "agency", "用户B标记", "mark-b@test.com")
|
||||||
|
|
||||||
|
msgs = await _seed_messages(test_db_session, data_a["user"]["id"], count=1)
|
||||||
|
msg_id = msgs[0].id
|
||||||
|
|
||||||
|
# 用户B尝试标记用户A的消息
|
||||||
|
resp = await client.put(
|
||||||
|
f"{MESSAGES_URL}/{msg_id}/read",
|
||||||
|
headers=_auth(data_b["access_token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404 # 看不到别人的消息,返回 404
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== PUT /messages/read-all ====================
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkAllAsRead:
|
||||||
|
"""标记所有消息已读"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_all_as_read(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
data = await _register(client, "brand", "全部已读", "all-read@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
user_id = data["user"]["id"]
|
||||||
|
|
||||||
|
await _seed_messages(test_db_session, user_id, count=5)
|
||||||
|
|
||||||
|
resp = await client.put(f"{MESSAGES_URL}/read-all", headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["count"] == 5
|
||||||
|
|
||||||
|
# 验证未读数为 0
|
||||||
|
count_resp = await client.get(f"{MESSAGES_URL}/unread-count", headers=_auth(token))
|
||||||
|
assert count_resp.json()["count"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_all_no_messages(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "无消息全读", "no-msg-all@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(f"{MESSAGES_URL}/read-all", headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["count"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_all_only_affects_own(self, client: AsyncClient, test_db_session: AsyncSession):
|
||||||
|
"""全部已读只影响自己的消息"""
|
||||||
|
data_a = await _register(client, "brand", "全读A", "all-own-a@test.com")
|
||||||
|
data_b = await _register(client, "agency", "全读B", "all-own-b@test.com")
|
||||||
|
|
||||||
|
await _seed_messages(test_db_session, data_a["user"]["id"], count=3)
|
||||||
|
await _seed_messages(test_db_session, data_b["user"]["id"], count=4)
|
||||||
|
|
||||||
|
# A 全部已读
|
||||||
|
await client.put(f"{MESSAGES_URL}/read-all", headers=_auth(data_a["access_token"]))
|
||||||
|
|
||||||
|
# B 的未读数不受影响
|
||||||
|
count_resp = await client.get(
|
||||||
|
f"{MESSAGES_URL}/unread-count", headers=_auth(data_b["access_token"]),
|
||||||
|
)
|
||||||
|
assert count_resp.json()["count"] == 4
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
用户资料 API 测试
|
||||||
|
覆盖: GET /profile, PUT /profile, PUT /profile/password
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE_URL = "/api/v1/profile"
|
||||||
|
REGISTER_URL = "/api/v1/auth/register"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _register(client: AsyncClient, role: str, name: str, email: str) -> dict:
|
||||||
|
resp = await client.post(REGISTER_URL, json={
|
||||||
|
"email": email,
|
||||||
|
"password": "Test1234!",
|
||||||
|
"name": name,
|
||||||
|
"role": role,
|
||||||
|
"email_code": "000000",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== GET /profile ====================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetProfile:
|
||||||
|
"""获取用户资料"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_brand_profile(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "测试品牌", "brand-profile@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.get(PROFILE_URL, headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
assert body["name"] == "测试品牌"
|
||||||
|
assert body["role"] == "brand"
|
||||||
|
assert body["email"] == "brand-profile@test.com"
|
||||||
|
assert body["brand"] is not None
|
||||||
|
assert body["brand"]["name"] == "测试品牌"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_agency_profile(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "agency", "测试代理商", "agency-profile@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.get(PROFILE_URL, headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
assert body["role"] == "agency"
|
||||||
|
assert body["agency"] is not None
|
||||||
|
assert body["agency"]["name"] == "测试代理商"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_creator_profile(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "creator", "测试达人", "creator-profile@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.get(PROFILE_URL, headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
assert body["role"] == "creator"
|
||||||
|
assert body["creator"] is not None
|
||||||
|
assert body["creator"]["name"] == "测试达人"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_profile_unauthenticated(self, client: AsyncClient):
|
||||||
|
resp = await client.get(PROFILE_URL)
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_profile_invalid_token(self, client: AsyncClient):
|
||||||
|
resp = await client.get(PROFILE_URL, headers=_auth("invalid-token"))
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== PUT /profile ====================
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateProfile:
|
||||||
|
"""更新用户资料"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_brand_name(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "原始品牌", "brand-update@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
PROFILE_URL,
|
||||||
|
json={"name": "新品牌名称"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["name"] == "新品牌名称"
|
||||||
|
assert body["brand"]["name"] == "新品牌名称"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_brand_contact(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "品牌联系人", "brand-contact@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
PROFILE_URL,
|
||||||
|
json={
|
||||||
|
"description": "品牌描述",
|
||||||
|
"contact_name": "张三",
|
||||||
|
"contact_phone": "13800000001",
|
||||||
|
"contact_email": "zhangsan@brand.com",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["brand"]["description"] == "品牌描述"
|
||||||
|
assert body["brand"]["contact_name"] == "张三"
|
||||||
|
assert body["brand"]["contact_phone"] == "13800000001"
|
||||||
|
assert body["brand"]["contact_email"] == "zhangsan@brand.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_agency_profile(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "agency", "代理商", "agency-update@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
PROFILE_URL,
|
||||||
|
json={
|
||||||
|
"name": "新代理商名",
|
||||||
|
"description": "专业MCN机构",
|
||||||
|
"contact_name": "李四",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["name"] == "新代理商名"
|
||||||
|
assert body["agency"]["name"] == "新代理商名"
|
||||||
|
assert body["agency"]["description"] == "专业MCN机构"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_creator_profile(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "creator", "达人", "creator-update@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
PROFILE_URL,
|
||||||
|
json={
|
||||||
|
"name": "新达人名",
|
||||||
|
"bio": "美食博主",
|
||||||
|
"douyin_account": "douyin123",
|
||||||
|
"xiaohongshu_account": "xhs456",
|
||||||
|
"bilibili_account": "bili789",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["name"] == "新达人名"
|
||||||
|
assert body["creator"]["name"] == "新达人名"
|
||||||
|
assert body["creator"]["bio"] == "美食博主"
|
||||||
|
assert body["creator"]["douyin_account"] == "douyin123"
|
||||||
|
assert body["creator"]["xiaohongshu_account"] == "xhs456"
|
||||||
|
assert body["creator"]["bilibili_account"] == "bili789"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_phone_and_avatar(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "头像测试", "avatar-test@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
PROFILE_URL,
|
||||||
|
json={
|
||||||
|
"phone": "13900000000",
|
||||||
|
"avatar": "https://example.com/avatar.png",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["phone"] == "13900000000"
|
||||||
|
assert body["avatar"] == "https://example.com/avatar.png"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_empty_body(self, client: AsyncClient):
|
||||||
|
"""空请求体不应报错"""
|
||||||
|
data = await _register(client, "brand", "空更新测试", "empty-update@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(PROFILE_URL, json={}, headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_profile_unauthenticated(self, client: AsyncClient):
|
||||||
|
resp = await client.put(PROFILE_URL, json={"name": "hack"})
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_persists(self, client: AsyncClient):
|
||||||
|
"""更新后重新 GET 应返回最新数据"""
|
||||||
|
data = await _register(client, "creator", "持久化测试", "persist@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
await client.put(
|
||||||
|
PROFILE_URL,
|
||||||
|
json={"bio": "更新后的简介"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get(PROFILE_URL, headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["creator"]["bio"] == "更新后的简介"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== PUT /profile/password ====================
|
||||||
|
|
||||||
|
|
||||||
|
class TestChangePassword:
|
||||||
|
"""修改密码"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_change_password_success(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "密码测试", "pwd-change@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"{PROFILE_URL}/password",
|
||||||
|
json={"old_password": "Test1234!", "new_password": "NewPass5678!"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "密码修改成功" in resp.json()["message"]
|
||||||
|
|
||||||
|
# 用新密码登录
|
||||||
|
login_resp = await client.post("/api/v1/auth/login", json={
|
||||||
|
"email": "pwd-change@test.com",
|
||||||
|
"password": "NewPass5678!",
|
||||||
|
})
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_change_password_wrong_old(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "错误密码", "wrong-pwd@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"{PROFILE_URL}/password",
|
||||||
|
json={"old_password": "WrongPassword!", "new_password": "NewPass!"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "原密码" in resp.json()["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_change_password_too_short(self, client: AsyncClient):
|
||||||
|
data = await _register(client, "brand", "短密码", "short-pwd@test.com")
|
||||||
|
token = data["access_token"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"{PROFILE_URL}/password",
|
||||||
|
json={"old_password": "Test1234!", "new_password": "12345"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422 # Pydantic validation
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_change_password_unauthenticated(self, client: AsyncClient):
|
||||||
|
resp = await client.put(
|
||||||
|
f"{PROFILE_URL}/password",
|
||||||
|
json={"old_password": "a", "new_password": "b"},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
规则管理 API 测试 (TDD - 红色阶段)
|
规则管理 API 测试
|
||||||
测试覆盖: 违禁词库、白名单、竞品库、平台规则
|
测试覆盖: 违禁词库、白名单、竞品库、平台规则、品牌方平台规则 CRUD
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from app.schemas.review import ScriptReviewResponse, ViolationType
|
from app.schemas.review import ScriptReviewResponse, ViolationType
|
||||||
@@ -343,7 +345,7 @@ class TestRuleConflictDetection:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_detect_brief_platform_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
async def test_detect_brief_platform_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
"""检测 Brief 与平台规则冲突"""
|
"""检测 Brief 与平台规则冲突(required_phrases)"""
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/rules/validate",
|
"/api/v1/rules/validate",
|
||||||
headers={"X-Tenant-ID": tenant_id},
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
@@ -351,7 +353,7 @@ class TestRuleConflictDetection:
|
|||||||
"brand_id": brand_id,
|
"brand_id": brand_id,
|
||||||
"platform": "douyin",
|
"platform": "douyin",
|
||||||
"brief_rules": {
|
"brief_rules": {
|
||||||
"required_phrases": ["绝对有效"], # 可能违反平台规则
|
"required_phrases": ["绝对有效"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -383,3 +385,669 @@ class TestRuleConflictDetection:
|
|||||||
assert "brief_rule" in conflict
|
assert "brief_rule" in conflict
|
||||||
assert "platform_rule" in conflict
|
assert "platform_rule" in conflict
|
||||||
assert "suggestion" in conflict
|
assert "suggestion" in conflict
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selling_points_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""selling_points 字段也参与冲突检测"""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/rules/validate",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"platform": "douyin",
|
||||||
|
"brief_rules": {
|
||||||
|
"selling_points": ["100%纯天然成分", "绝对安全"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["conflicts"]) >= 2 # "100%" 和 "绝对" 都命中
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_conflict_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""无冲突时返回空列表"""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/rules/validate",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"platform": "douyin",
|
||||||
|
"brief_rules": {
|
||||||
|
"selling_points": ["温和护肤", "适合敏感肌"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert data["conflicts"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_duration_conflict_brief_max_below_platform_min(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""Brief 最长时长低于平台最短要求"""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/rules/validate",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"platform": "douyin", # 硬编码 min_seconds=7
|
||||||
|
"brief_rules": {
|
||||||
|
"max_duration": 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["conflicts"]) >= 1
|
||||||
|
assert any("时长" in c["brief_rule"] for c in data["conflicts"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_db_rules_participate_in_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""DB 中 active 的规则参与冲突检测"""
|
||||||
|
headers = {"X-Tenant-ID": tenant_id}
|
||||||
|
|
||||||
|
# 创建并确认一条包含自定义违禁词的 DB 平台规则
|
||||||
|
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
rule_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
custom_rules = {
|
||||||
|
"forbidden_words": ["自定义违禁词ABC"],
|
||||||
|
"restricted_words": [],
|
||||||
|
"duration": {"min_seconds": 15, "max_seconds": 120},
|
||||||
|
"content_requirements": [],
|
||||||
|
"other_rules": [],
|
||||||
|
}
|
||||||
|
await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||||
|
headers=headers,
|
||||||
|
json={"parsed_rules": custom_rules},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证 DB 违禁词参与检测
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/rules/validate",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"platform": "douyin",
|
||||||
|
"brief_rules": {
|
||||||
|
"selling_points": ["这个自定义违禁词ABC很好"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["conflicts"]) >= 1
|
||||||
|
assert any("自定义违禁词ABC" in c["suggestion"] for c in data["conflicts"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_db_duration_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""DB 规则中的时长限制参与检测"""
|
||||||
|
headers = {"X-Tenant-ID": tenant_id}
|
||||||
|
|
||||||
|
# 创建 DB 规则:max_seconds=60
|
||||||
|
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
|
||||||
|
rule_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
custom_rules = {
|
||||||
|
"forbidden_words": [],
|
||||||
|
"restricted_words": [],
|
||||||
|
"duration": {"min_seconds": 10, "max_seconds": 60},
|
||||||
|
"content_requirements": [],
|
||||||
|
"other_rules": [],
|
||||||
|
}
|
||||||
|
await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||||
|
headers=headers,
|
||||||
|
json={"parsed_rules": custom_rules},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Brief 最短时长 90s > 平台最长 60s → 冲突
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/rules/validate",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"platform": "xiaohongshu",
|
||||||
|
"brief_rules": {
|
||||||
|
"min_duration": 90,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["conflicts"]) >= 1
|
||||||
|
assert any("最长限制" in c["platform_rule"] for c in data["conflicts"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_db_and_hardcoded_rules_merge(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""DB 规则与硬编码规则合并检测"""
|
||||||
|
headers = {"X-Tenant-ID": tenant_id}
|
||||||
|
|
||||||
|
# DB 规则只包含自定义违禁词
|
||||||
|
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
rule_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||||
|
headers=headers,
|
||||||
|
json={"parsed_rules": {
|
||||||
|
"forbidden_words": ["DB专属词"],
|
||||||
|
"restricted_words": [],
|
||||||
|
"duration": None,
|
||||||
|
"content_requirements": [],
|
||||||
|
"other_rules": [],
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# selling_points 同时包含 DB 违禁词和硬编码违禁词
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/rules/validate",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"platform": "douyin",
|
||||||
|
"brief_rules": {
|
||||||
|
"selling_points": ["这是DB专属词内容", "最好的选择"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
# 应同时检出 DB 违禁词和硬编码违禁词
|
||||||
|
suggestions = [c["suggestion"] for c in data["conflicts"]]
|
||||||
|
assert any("DB专属词" in s for s in suggestions)
|
||||||
|
assert any("最好" in s for s in suggestions)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_platform_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""未知平台返回空冲突(无硬编码规则,无 DB 规则)"""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/rules/validate",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"platform": "unknown_platform",
|
||||||
|
"brief_rules": {
|
||||||
|
"selling_points": ["最好的产品"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert data["conflicts"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 品牌方平台规则(文档上传 + AI 解析) ====================
|
||||||
|
|
||||||
|
# Mock AI 解析返回的规则数据
|
||||||
|
MOCK_PARSED_RULES = {
|
||||||
|
"forbidden_words": ["绝对有效", "最强", "第一"],
|
||||||
|
"restricted_words": [
|
||||||
|
{"word": "推荐", "condition": "不能用于医疗产品", "suggestion": "建议改为'供参考'"}
|
||||||
|
],
|
||||||
|
"duration": {"min_seconds": 7, "max_seconds": 60},
|
||||||
|
"content_requirements": ["必须展示产品正面", "需口播品牌名"],
|
||||||
|
"other_rules": [
|
||||||
|
{"rule": "字幕要求", "description": "视频必须添加中文字幕"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_AI_JSON_RESPONSE = json.dumps(MOCK_PARSED_RULES, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_ai_client_for_parse():
|
||||||
|
"""创建用于文档解析的 mock AI 客户端"""
|
||||||
|
client = MagicMock()
|
||||||
|
client.chat_completion = AsyncMock(return_value=MagicMock(
|
||||||
|
content=MOCK_AI_JSON_RESPONSE,
|
||||||
|
))
|
||||||
|
client.close = AsyncMock()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_platform_rule(
|
||||||
|
client: AsyncClient,
|
||||||
|
tenant_id: str,
|
||||||
|
brand_id: str,
|
||||||
|
platform: str = "douyin",
|
||||||
|
document_name: str = "规则文档.pdf",
|
||||||
|
) -> dict:
|
||||||
|
"""辅助函数:创建一条 draft 平台规则"""
|
||||||
|
with patch(
|
||||||
|
"app.api.rules.DocumentParser.download_and_parse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="这是平台规则文档内容...",
|
||||||
|
), patch(
|
||||||
|
"app.api.rules.AIServiceFactory.get_client",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=_mock_ai_client_for_parse(),
|
||||||
|
), patch(
|
||||||
|
"app.api.rules.AIServiceFactory.get_config",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=MagicMock(models={"text": "gpt-4o"}),
|
||||||
|
):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/rules/platform-rules/parse",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={
|
||||||
|
"document_url": "https://tos.example.com/rules.pdf",
|
||||||
|
"document_name": document_name,
|
||||||
|
"platform": platform,
|
||||||
|
"brand_id": brand_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrandPlatformRuleParse:
|
||||||
|
"""品牌方平台规则 — 上传文档 + AI 解析"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_returns_201_draft(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""上传文档解析返回 201,状态为 draft"""
|
||||||
|
resp = await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "draft"
|
||||||
|
assert data["platform"] == "douyin"
|
||||||
|
assert data["brand_id"] == brand_id
|
||||||
|
assert data["id"].startswith("pr-")
|
||||||
|
assert data["document_name"] == "规则文档.pdf"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_returns_parsed_rules(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""解析后返回结构化规则"""
|
||||||
|
resp = await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
rules = data["parsed_rules"]
|
||||||
|
assert "forbidden_words" in rules
|
||||||
|
assert "restricted_words" in rules
|
||||||
|
assert "duration" in rules
|
||||||
|
assert "content_requirements" in rules
|
||||||
|
assert "other_rules" in rules
|
||||||
|
assert len(rules["forbidden_words"]) == 3
|
||||||
|
assert "绝对有效" in rules["forbidden_words"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_empty_document_returns_400(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""空文档返回 400"""
|
||||||
|
with patch(
|
||||||
|
"app.api.rules.DocumentParser.download_and_parse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=" ",
|
||||||
|
):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/rules/platform-rules/parse",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={
|
||||||
|
"document_url": "https://tos.example.com/empty.pdf",
|
||||||
|
"document_name": "empty.pdf",
|
||||||
|
"platform": "douyin",
|
||||||
|
"brand_id": brand_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "内容为空" in resp.json()["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_unsupported_format_returns_400(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""不支持的文件格式返回 400"""
|
||||||
|
with patch(
|
||||||
|
"app.api.rules.DocumentParser.download_and_parse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=ValueError("不支持的文件格式: zip"),
|
||||||
|
):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/rules/platform-rules/parse",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={
|
||||||
|
"document_url": "https://tos.example.com/file.zip",
|
||||||
|
"document_name": "file.zip",
|
||||||
|
"platform": "douyin",
|
||||||
|
"brand_id": brand_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_ai_failure_returns_empty_rules(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""AI 解析失败时返回空规则结构(降级处理)"""
|
||||||
|
with patch(
|
||||||
|
"app.api.rules.DocumentParser.download_and_parse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="文档内容...",
|
||||||
|
), patch(
|
||||||
|
"app.api.rules.AIServiceFactory.get_client",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/rules/platform-rules/parse",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={
|
||||||
|
"document_url": "https://tos.example.com/rules.pdf",
|
||||||
|
"document_name": "rules.pdf",
|
||||||
|
"platform": "douyin",
|
||||||
|
"brand_id": brand_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
rules = resp.json()["parsed_rules"]
|
||||||
|
assert rules["forbidden_words"] == []
|
||||||
|
assert rules["content_requirements"] == []
|
||||||
|
assert rules["duration"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_multiple_platforms(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""同一品牌方可以上传不同平台的规则"""
|
||||||
|
r1 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
r2 = await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
|
||||||
|
|
||||||
|
assert r1.status_code == 201
|
||||||
|
assert r2.status_code == 201
|
||||||
|
assert r1.json()["platform"] == "douyin"
|
||||||
|
assert r2.json()["platform"] == "xiaohongshu"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrandPlatformRuleConfirm:
|
||||||
|
"""品牌方平台规则 — 确认/生效"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_confirm_sets_active(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""确认规则后状态变为 active"""
|
||||||
|
# 先创建 draft
|
||||||
|
create_resp = await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
rule_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
# 确认
|
||||||
|
confirm_resp = await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={
|
||||||
|
"parsed_rules": MOCK_PARSED_RULES,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert confirm_resp.status_code == 200
|
||||||
|
data = confirm_resp.json()
|
||||||
|
assert data["status"] == "active"
|
||||||
|
assert data["id"] == rule_id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_confirm_with_edited_rules(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""品牌方修改后确认"""
|
||||||
|
create_resp = await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
rule_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
edited_rules = {
|
||||||
|
"forbidden_words": ["绝对有效", "最强", "第一", "新增的违禁词"],
|
||||||
|
"restricted_words": [],
|
||||||
|
"duration": {"min_seconds": 10, "max_seconds": 120},
|
||||||
|
"content_requirements": ["必须展示产品"],
|
||||||
|
"other_rules": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm_resp = await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={"parsed_rules": edited_rules},
|
||||||
|
)
|
||||||
|
assert confirm_resp.status_code == 200
|
||||||
|
data = confirm_resp.json()
|
||||||
|
assert "新增的违禁词" in data["parsed_rules"]["forbidden_words"]
|
||||||
|
assert data["parsed_rules"]["duration"]["min_seconds"] == 10
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_confirm_deactivates_old_rule(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""确认新规则后旧的 active 规则变 inactive"""
|
||||||
|
# 创建并确认第一条规则
|
||||||
|
r1 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
rule1_id = r1.json()["id"]
|
||||||
|
await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule1_id}/confirm",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={"parsed_rules": MOCK_PARSED_RULES},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建并确认第二条规则(同品牌同平台)
|
||||||
|
r2 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
rule2_id = r2.json()["id"]
|
||||||
|
await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule2_id}/confirm",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={"parsed_rules": MOCK_PARSED_RULES},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询所有规则 — rule1 应该变 inactive,rule2 应该 active
|
||||||
|
list_resp = await client.get(
|
||||||
|
f"/api/v1/rules/platform-rules?brand_id={brand_id}&platform=douyin",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
rules = list_resp.json()["items"]
|
||||||
|
rule1 = next(r for r in rules if r["id"] == rule1_id)
|
||||||
|
rule2 = next(r for r in rules if r["id"] == rule2_id)
|
||||||
|
|
||||||
|
assert rule1["status"] == "inactive"
|
||||||
|
assert rule2["status"] == "active"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_confirm_nonexistent_rule_returns_404(self, client: AsyncClient, tenant_id: str):
|
||||||
|
"""确认不存在的规则返回 404"""
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/rules/platform-rules/pr-nonexist/confirm",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={"parsed_rules": MOCK_PARSED_RULES},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_confirm_cross_tenant_returns_404(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""不同租户确认规则返回 404(租户隔离)"""
|
||||||
|
create_resp = await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
rule_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||||
|
headers={"X-Tenant-ID": "other-tenant-xxx"},
|
||||||
|
json={"parsed_rules": MOCK_PARSED_RULES},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrandPlatformRuleList:
|
||||||
|
"""品牌方平台规则 — 列表查询"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_empty_returns_200(self, client: AsyncClient, tenant_id: str):
|
||||||
|
"""没有规则时返回空列表"""
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/rules/platform-rules",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["items"] == []
|
||||||
|
assert data["total"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_returns_created_rules(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""创建规则后列表包含该规则"""
|
||||||
|
await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/rules/platform-rules?brand_id={brand_id}",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] == 2
|
||||||
|
platforms = {r["platform"] for r in data["items"]}
|
||||||
|
assert platforms == {"douyin", "xiaohongshu"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_filter_by_platform(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""按平台筛选"""
|
||||||
|
await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/rules/platform-rules?brand_id={brand_id}&platform=douyin",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["items"][0]["platform"] == "douyin"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_filter_by_status(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""按状态筛选"""
|
||||||
|
r = await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
rule_id = r.json()["id"]
|
||||||
|
|
||||||
|
# 确认一条
|
||||||
|
await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
json={"parsed_rules": MOCK_PARSED_RULES},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 再创建一条 draft
|
||||||
|
await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
|
||||||
|
# 只查 active
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/rules/platform-rules?brand_id={brand_id}&status=active",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
active_rules = resp.json()["items"]
|
||||||
|
assert all(r["status"] == "active" for r in active_rules)
|
||||||
|
|
||||||
|
# 只查 draft
|
||||||
|
resp2 = await client.get(
|
||||||
|
f"/api/v1/rules/platform-rules?brand_id={brand_id}&status=draft",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
draft_rules = resp2.json()["items"]
|
||||||
|
assert all(r["status"] == "draft" for r in draft_rules)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_tenant_isolation(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""租户隔离:不同租户看不到彼此的规则"""
|
||||||
|
await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/rules/platform-rules",
|
||||||
|
headers={"X-Tenant-ID": "another-tenant-yyy"},
|
||||||
|
)
|
||||||
|
assert resp.json()["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrandPlatformRuleDelete:
|
||||||
|
"""品牌方平台规则 — 删除"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_returns_204(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""删除规则返回 204"""
|
||||||
|
r = await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
rule_id = r.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_actually_removes(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""删除后列表中不再包含该规则"""
|
||||||
|
r = await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
rule_id = r.json()["id"]
|
||||||
|
|
||||||
|
await client.delete(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/rules/platform-rules?brand_id={brand_id}",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
ids = [r["id"] for r in resp.json()["items"]]
|
||||||
|
assert rule_id not in ids
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_nonexistent_returns_404(self, client: AsyncClient, tenant_id: str):
|
||||||
|
"""删除不存在的规则返回 404"""
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/v1/rules/platform-rules/pr-nonexist",
|
||||||
|
headers={"X-Tenant-ID": tenant_id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_cross_tenant_returns_404(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""不同租户删除规则返回 404(租户隔离)"""
|
||||||
|
r = await _create_platform_rule(client, tenant_id, brand_id)
|
||||||
|
rule_id = r.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule_id}",
|
||||||
|
headers={"X-Tenant-ID": "other-tenant-zzz"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrandPlatformRuleLifecycle:
|
||||||
|
"""品牌方平台规则 — 完整生命周期"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_lifecycle(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
|
"""完整流程: 上传解析 → 确认生效 → 重新上传 → 旧规则停用"""
|
||||||
|
headers = {"X-Tenant-ID": tenant_id}
|
||||||
|
|
||||||
|
# 1. 上传并解析
|
||||||
|
r1 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
assert r1.status_code == 201
|
||||||
|
rule1_id = r1.json()["id"]
|
||||||
|
assert r1.json()["status"] == "draft"
|
||||||
|
|
||||||
|
# 2. 确认生效
|
||||||
|
confirm_resp = await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule1_id}/confirm",
|
||||||
|
headers=headers,
|
||||||
|
json={"parsed_rules": MOCK_PARSED_RULES},
|
||||||
|
)
|
||||||
|
assert confirm_resp.json()["status"] == "active"
|
||||||
|
|
||||||
|
# 3. 重新上传新规则
|
||||||
|
r2 = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
|
||||||
|
rule2_id = r2.json()["id"]
|
||||||
|
assert r2.json()["status"] == "draft"
|
||||||
|
|
||||||
|
# 4. 确认新规则
|
||||||
|
await client.put(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule2_id}/confirm",
|
||||||
|
headers=headers,
|
||||||
|
json={"parsed_rules": MOCK_PARSED_RULES},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 验证旧规则自动停用
|
||||||
|
list_resp = await client.get(
|
||||||
|
f"/api/v1/rules/platform-rules?brand_id={brand_id}&platform=douyin",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
rules = list_resp.json()["items"]
|
||||||
|
rule1 = next(r for r in rules if r["id"] == rule1_id)
|
||||||
|
rule2 = next(r for r in rules if r["id"] == rule2_id)
|
||||||
|
assert rule1["status"] == "inactive"
|
||||||
|
assert rule2["status"] == "active"
|
||||||
|
|
||||||
|
# 6. 删除旧规则
|
||||||
|
del_resp = await client.delete(
|
||||||
|
f"/api/v1/rules/platform-rules/{rule1_id}",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert del_resp.status_code == 204
|
||||||
|
|
||||||
|
# 7. 验证只剩新规则
|
||||||
|
final_resp = await client.get(
|
||||||
|
f"/api/v1/rules/platform-rules?brand_id={brand_id}&platform=douyin",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert final_resp.json()["total"] == 1
|
||||||
|
assert final_resp.json()["items"][0]["id"] == rule2_id
|
||||||
|
|||||||
@@ -215,7 +215,11 @@ class TestSellingPointCheck:
|
|||||||
"content": "这个产品很好用",
|
"content": "这个产品很好用",
|
||||||
"platform": "douyin",
|
"platform": "douyin",
|
||||||
"brand_id": brand_id,
|
"brand_id": brand_id,
|
||||||
"required_points": ["功效说明", "使用方法", "品牌名称"],
|
"selling_points": [
|
||||||
|
{"content": "功效说明", "priority": "core"},
|
||||||
|
{"content": "使用方法", "priority": "core"},
|
||||||
|
{"content": "品牌名称", "priority": "recommended"},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -223,6 +227,9 @@ class TestSellingPointCheck:
|
|||||||
|
|
||||||
assert parsed.missing_points is not None
|
assert parsed.missing_points is not None
|
||||||
assert isinstance(parsed.missing_points, list)
|
assert isinstance(parsed.missing_points, list)
|
||||||
|
# 验证多维度评分存在
|
||||||
|
assert parsed.dimensions is not None
|
||||||
|
assert parsed.dimensions.brief_match is not None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_all_points_covered(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
async def test_all_points_covered(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||||
@@ -234,7 +241,11 @@ class TestSellingPointCheck:
|
|||||||
"content": "品牌A的护肤精华,每天早晚各用一次,可以让肌肤更水润",
|
"content": "品牌A的护肤精华,每天早晚各用一次,可以让肌肤更水润",
|
||||||
"platform": "douyin",
|
"platform": "douyin",
|
||||||
"brand_id": brand_id,
|
"brand_id": brand_id,
|
||||||
"required_points": ["品牌名称", "使用方法", "功效说明"],
|
"selling_points": [
|
||||||
|
{"content": "护肤精华", "priority": "core"},
|
||||||
|
{"content": "早晚各用一次", "priority": "core"},
|
||||||
|
{"content": "肌肤更水润", "priority": "recommended"},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
# --- API 地址 ---
|
# --- API 地址 ---
|
||||||
# 后端 API 基础 URL(浏览器端访问)
|
# 后端 API 基础 URL(浏览器端访问)
|
||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
NEXT_PUBLIC_API_BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
# --- Mock 模式 ---
|
# --- Mock 模式 ---
|
||||||
# 设为 true 使用前端 mock 数据(development 环境下默认开启)
|
# 设为 true 使用前端 mock 数据(development 环境下默认开启)
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ function mapTaskToAppeal(task: TaskResponse): Appeal {
|
|||||||
taskTitle: task.name,
|
taskTitle: task.name,
|
||||||
creatorId: task.creator.id,
|
creatorId: task.creator.id,
|
||||||
creatorName: task.creator.name,
|
creatorName: task.creator.name,
|
||||||
platform: 'douyin', // Backend does not expose platform on task; default for now
|
platform: task.project?.platform || 'douyin',
|
||||||
type,
|
type,
|
||||||
contentType,
|
contentType,
|
||||||
reason: task.appeal_reason || '申诉',
|
reason: task.appeal_reason || '申诉',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -141,7 +141,7 @@ export default function AgencyBriefsPage() {
|
|||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
brandName: project.brand_name || '未知品牌',
|
brandName: project.brand_name || '未知品牌',
|
||||||
platform: 'douyin', // 后端暂无 platform 字段,默认值
|
platform: project.platform || 'douyin',
|
||||||
status: hasBrief ? 'configured' : 'pending',
|
status: hasBrief ? 'configured' : 'pending',
|
||||||
uploadedAt: project.created_at.split('T')[0],
|
uploadedAt: project.created_at.split('T')[0],
|
||||||
configuredAt: hasBrief ? brief.updated_at.split('T')[0] : null,
|
configuredAt: hasBrief ? brief.updated_at.split('T')[0] : null,
|
||||||
@@ -156,7 +156,7 @@ export default function AgencyBriefsPage() {
|
|||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
brandName: project.brand_name || '未知品牌',
|
brandName: project.brand_name || '未知品牌',
|
||||||
platform: 'douyin',
|
platform: project.platform || 'douyin',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
uploadedAt: project.created_at.split('T')[0],
|
uploadedAt: project.created_at.split('T')[0],
|
||||||
configuredAt: null,
|
configuredAt: null,
|
||||||
@@ -204,8 +204,8 @@ export default function AgencyBriefsPage() {
|
|||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-text-primary">Brief 配置</h1>
|
<h1 className="text-2xl font-bold text-text-primary">任务配置</h1>
|
||||||
<p className="text-sm text-text-secondary mt-1">配置项目 Brief,设置审核规则</p>
|
<p className="text-sm text-text-secondary mt-1">配置项目 Brief,分配达人任务</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg font-medium">
|
<span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg font-medium">
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export default function AgencyCreatorsPage() {
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
name: task.name,
|
name: task.name,
|
||||||
projectName: task.project?.name || '-',
|
projectName: task.project?.name || '-',
|
||||||
platform: 'douyin', // 后端暂未返回平台信息,默认
|
platform: task.project?.platform || 'douyin',
|
||||||
stage: mapBackendStage(task.stage),
|
stage: mapBackendStage(task.stage),
|
||||||
appealRemaining: task.appeal_count,
|
appealRemaining: task.appeal_count,
|
||||||
appealUsed: task.is_appeal ? 1 : 0,
|
appealUsed: task.is_appeal ? 1 : 0,
|
||||||
@@ -477,15 +477,36 @@ export default function AgencyCreatorsPage() {
|
|||||||
setOpenMenuId(null)
|
setOpenMenuId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确认分配项目
|
// 确认分配项目(创建任务)
|
||||||
const handleConfirmAssign = () => {
|
const handleConfirmAssign = async () => {
|
||||||
const projectList = USE_MOCK ? mockProjects : projects
|
const projectList = USE_MOCK ? mockProjects : projects
|
||||||
if (assignModal.creator && selectedProject) {
|
if (!assignModal.creator || !selectedProject) return
|
||||||
|
|
||||||
const project = projectList.find(p => p.id === selectedProject)
|
const project = projectList.find(p => p.id === selectedProject)
|
||||||
|
|
||||||
|
if (USE_MOCK) {
|
||||||
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}」`)
|
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}」`)
|
||||||
}
|
|
||||||
setAssignModal({ open: false, creator: null })
|
setAssignModal({ open: false, creator: null })
|
||||||
setSelectedProject('')
|
setSelectedProject('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.createTask({
|
||||||
|
project_id: selectedProject,
|
||||||
|
creator_id: assignModal.creator.creatorId,
|
||||||
|
})
|
||||||
|
toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}」`)
|
||||||
|
setAssignModal({ open: false, creator: null })
|
||||||
|
setSelectedProject('')
|
||||||
|
await fetchData() // 刷新列表
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '分配失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 骨架屏
|
// 骨架屏
|
||||||
@@ -761,17 +782,34 @@ export default function AgencyCreatorsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-text-tertiary">{creator.joinedAt}</td>
|
<td className="px-6 py-4 text-sm text-text-tertiary">{creator.joinedAt}</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="relative">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
onClick={() => handleOpenAssign(creator)}
|
||||||
onClick={() => setOpenMenuId(openMenuId === creator.id ? null : creator.id)}
|
className="px-3 py-1.5 text-xs font-medium text-accent-indigo bg-accent-indigo/10 hover:bg-accent-indigo/20 rounded-lg transition-colors flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<MoreVertical size={16} />
|
<FolderPlus size={13} />
|
||||||
</Button>
|
分配项目
|
||||||
{/* 下拉菜单 */}
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenDelete(creator)}
|
||||||
|
className="p-1.5 text-text-tertiary hover:text-accent-coral hover:bg-accent-coral/10 rounded-lg transition-colors"
|
||||||
|
title="移除达人"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenMenuId(openMenuId === creator.id ? null : creator.id)}
|
||||||
|
className="p-1.5 text-text-tertiary hover:text-text-primary hover:bg-bg-elevated rounded-lg transition-colors"
|
||||||
|
title="更多操作"
|
||||||
|
>
|
||||||
|
<MoreVertical size={14} />
|
||||||
|
</button>
|
||||||
{openMenuId === creator.id && (
|
{openMenuId === creator.id && (
|
||||||
<div className="absolute right-0 top-full mt-1 w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
|
<div className="absolute right-0 top-full mt-1 w-36 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenRemark(creator)}
|
onClick={() => handleOpenRemark(creator)}
|
||||||
@@ -780,25 +818,10 @@ export default function AgencyCreatorsPage() {
|
|||||||
<MessageSquareText size={14} className="text-text-secondary" />
|
<MessageSquareText size={14} className="text-text-secondary" />
|
||||||
{creator.remark ? '编辑备注' : '添加备注'}
|
{creator.remark ? '编辑备注' : '添加备注'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOpenAssign(creator)}
|
|
||||||
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<FolderPlus size={14} className="text-text-secondary" />
|
|
||||||
分配到项目
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOpenDelete(creator)}
|
|
||||||
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
移除达人
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* 展开的任务列表 */}
|
{/* 展开的任务列表 */}
|
||||||
@@ -1042,8 +1065,8 @@ export default function AgencyCreatorsPage() {
|
|||||||
<Button variant="ghost" onClick={() => { setAssignModal({ open: false, creator: null }); setSelectedProject(''); }}>
|
<Button variant="ghost" onClick={() => { setAssignModal({ open: false, creator: null }); setSelectedProject(''); }}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirmAssign} disabled={!selectedProject}>
|
<Button onClick={handleConfirmAssign} disabled={!selectedProject || submitting}>
|
||||||
<FolderPlus size={16} />
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : <FolderPlus size={16} />}
|
||||||
确认分配
|
确认分配
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
||||||
@@ -43,6 +45,11 @@ type MessageType =
|
|||||||
| 'task_deadline' // 任务截止提醒
|
| 'task_deadline' // 任务截止提醒
|
||||||
| 'brand_brief_updated' // 品牌方更新了Brief
|
| 'brand_brief_updated' // 品牌方更新了Brief
|
||||||
| 'system_notice' // 系统通知
|
| 'system_notice' // 系统通知
|
||||||
|
| 'new_task' // 新任务
|
||||||
|
| 'pass' // 审核通过
|
||||||
|
| 'reject' // 审核驳回
|
||||||
|
| 'force_pass' // 强制通过
|
||||||
|
| 'approve' // 审核批准
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string
|
id: string
|
||||||
@@ -286,9 +293,52 @@ const mockMessages: Message[] = [
|
|||||||
|
|
||||||
export default function AgencyMessagesPage() {
|
export default function AgencyMessagesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [messages, setMessages] = useState(mockMessages)
|
const [messages, setMessages] = useState<Message[]>(mockMessages)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
|
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.getMessages({ page: 1, page_size: 50 })
|
||||||
|
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: iconCfg.icon,
|
||||||
|
iconColor: iconCfg.iconColor,
|
||||||
|
bgColor: iconCfg.bgColor,
|
||||||
|
taskId: item.related_task_id || undefined,
|
||||||
|
projectId: item.related_project_id || undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setMessages(mapped)
|
||||||
|
} catch {
|
||||||
|
// 加载失败保持 mock 数据
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
const unreadCount = messages.filter(m => !m.read).length
|
const unreadCount = messages.filter(m => !m.read).length
|
||||||
const pendingAppealRequests = messages.filter(m => m.appealRequest?.status === 'pending').length
|
const pendingAppealRequests = messages.filter(m => m.appealRequest?.status === 'pending').length
|
||||||
const pendingReviewCount = messages.filter(m =>
|
const pendingReviewCount = messages.filter(m =>
|
||||||
@@ -310,12 +360,18 @@ export default function AgencyMessagesPage() {
|
|||||||
|
|
||||||
const filteredMessages = getFilteredMessages()
|
const filteredMessages = getFilteredMessages()
|
||||||
|
|
||||||
const markAsRead = (id: string) => {
|
const markAsRead = async (id: string) => {
|
||||||
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
|
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
try { await api.markMessageAsRead(id) } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAllAsRead = () => {
|
const markAllAsRead = async () => {
|
||||||
setMessages(prev => prev.map(m => ({ ...m, read: true })))
|
setMessages(prev => prev.map(m => ({ ...m, read: true })))
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
try { await api.markAllMessagesAsRead() } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理申诉次数请求
|
// 处理申诉次数请求
|
||||||
|
|||||||
@@ -96,8 +96,12 @@ function getTaskUrgencyLevel(task: TaskResponse): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTaskUrgencyTitle(task: TaskResponse): string {
|
function getTaskUrgencyTitle(task: TaskResponse): string {
|
||||||
const type = task.stage.includes('video') ? '视频' : '脚本'
|
return `${task.project.name} · ${task.name}`
|
||||||
return `${task.creator.name}${type} - ${task.name}`
|
}
|
||||||
|
|
||||||
|
function getPlatformLabel(platform?: string | null): string {
|
||||||
|
const map: Record<string, string> = { douyin: '抖音', xiaohongshu: '小红书', bilibili: 'B站', kuaishou: '快手' }
|
||||||
|
return platform ? (map[platform] || platform) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTaskTimeAgo(dateStr: string): string {
|
function getTaskTimeAgo(dateStr: string): string {
|
||||||
@@ -182,13 +186,19 @@ export default function AgencyDashboard() {
|
|||||||
if (loading || !stats) return <DashboardSkeleton />
|
if (loading || !stats) return <DashboardSkeleton />
|
||||||
|
|
||||||
// Build urgent todos from pending tasks (top 3)
|
// Build urgent todos from pending tasks (top 3)
|
||||||
const urgentTodos = pendingTasks.slice(0, 3).map(task => ({
|
const urgentTodos = pendingTasks.slice(0, 3).map(task => {
|
||||||
|
const type = task.stage.includes('video') ? '视频' : '脚本'
|
||||||
|
const platformLabel = getPlatformLabel(task.project.platform)
|
||||||
|
const brandLabel = task.project.brand_name || ''
|
||||||
|
const desc = [task.creator.name, brandLabel, platformLabel, type].filter(Boolean).join(' · ')
|
||||||
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: getTaskUrgencyTitle(task),
|
title: getTaskUrgencyTitle(task),
|
||||||
description: task.project.name,
|
description: desc,
|
||||||
time: getTaskTimeAgo(task.updated_at),
|
time: getTaskTimeAgo(task.updated_at),
|
||||||
level: getTaskUrgencyLevel(task),
|
level: getTaskUrgencyLevel(task),
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 min-h-0">
|
<div className="space-y-6 min-h-0">
|
||||||
@@ -316,6 +326,9 @@ export default function AgencyDashboard() {
|
|||||||
{project.brand_name && (
|
{project.brand_name && (
|
||||||
<span className="text-xs text-text-tertiary">({project.brand_name})</span>
|
<span className="text-xs text-text-tertiary">({project.brand_name})</span>
|
||||||
)}
|
)}
|
||||||
|
{project.platform && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-indigo/10 text-accent-indigo">{getPlatformLabel(project.platform)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-text-secondary">
|
<span className="text-sm text-text-secondary">
|
||||||
{project.task_count} 个任务
|
{project.task_count} 个任务
|
||||||
@@ -356,6 +369,7 @@ export default function AgencyDashboard() {
|
|||||||
<th className="pb-3 font-medium">类型</th>
|
<th className="pb-3 font-medium">类型</th>
|
||||||
<th className="pb-3 font-medium">达人</th>
|
<th className="pb-3 font-medium">达人</th>
|
||||||
<th className="pb-3 font-medium">品牌</th>
|
<th className="pb-3 font-medium">品牌</th>
|
||||||
|
<th className="pb-3 font-medium">平台</th>
|
||||||
<th className="pb-3 font-medium">AI评分</th>
|
<th className="pb-3 font-medium">AI评分</th>
|
||||||
<th className="pb-3 font-medium">提交时间</th>
|
<th className="pb-3 font-medium">提交时间</th>
|
||||||
<th className="pb-3 font-medium">操作</th>
|
<th className="pb-3 font-medium">操作</th>
|
||||||
@@ -369,7 +383,9 @@ export default function AgencyDashboard() {
|
|||||||
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
|
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium text-text-primary">{task.name}</div>
|
<div>
|
||||||
|
<div className="font-medium text-text-primary">{task.project.name} · {task.name}</div>
|
||||||
|
</div>
|
||||||
{task.is_appeal && (
|
{task.is_appeal && (
|
||||||
<span className="px-1.5 py-0.5 text-xs bg-accent-amber/20 text-accent-amber rounded">
|
<span className="px-1.5 py-0.5 text-xs bg-accent-amber/20 text-accent-amber rounded">
|
||||||
申诉
|
申诉
|
||||||
@@ -385,7 +401,8 @@ export default function AgencyDashboard() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 text-text-secondary">{task.creator.name}</td>
|
<td className="py-4 text-text-secondary">{task.creator.name}</td>
|
||||||
<td className="py-4 text-text-secondary">{task.project.brand_name || task.project.name}</td>
|
<td className="py-4 text-text-secondary">{task.project.brand_name || '-'}</td>
|
||||||
|
<td className="py-4 text-text-secondary">{getPlatformLabel(task.project.platform) || '-'}</td>
|
||||||
<td className="py-4">
|
<td className="py-4">
|
||||||
{aiScore != null ? (
|
{aiScore != null ? (
|
||||||
<span className={`font-medium ${
|
<span className={`font-medium ${
|
||||||
@@ -409,7 +426,7 @@ export default function AgencyDashboard() {
|
|||||||
)
|
)
|
||||||
}) : (
|
}) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="py-8 text-center text-text-tertiary">暂无待审核任务</td>
|
<td colSpan={8} className="py-8 text-center text-text-tertiary">暂无待审核任务</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@@ -203,7 +205,13 @@ export default function AgencyCompanyPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
if (USE_MOCK) {
|
||||||
|
// Mock 模式:模拟保存延迟
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
// TODO: 后端企业信息保存 API 待实现,暂时使用 mock 行为
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
}
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
toast.success('公司信息已保存')
|
toast.success('公司信息已保存')
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
@@ -32,6 +34,24 @@ export default function AgencyProfileEditPage() {
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) return
|
||||||
|
try {
|
||||||
|
const profile = await api.getProfile()
|
||||||
|
setFormData({
|
||||||
|
avatar: profile.name?.[0] || '?',
|
||||||
|
name: profile.name || '',
|
||||||
|
agencyId: profile.agency?.id || '--',
|
||||||
|
phone: profile.phone || '',
|
||||||
|
email: profile.email || '',
|
||||||
|
position: profile.agency?.contact_name || '',
|
||||||
|
department: '',
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
const handleCopyId = async () => {
|
const handleCopyId = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(formData.agencyId)
|
await navigator.clipboard.writeText(formData.agencyId)
|
||||||
@@ -44,7 +64,21 @@ export default function AgencyProfileEditPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
if (USE_MOCK) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await api.updateProfile({
|
||||||
|
name: formData.name,
|
||||||
|
phone: formData.phone,
|
||||||
|
contact_name: formData.position,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || '保存失败')
|
||||||
|
setIsSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
toast.success('个人信息已保存')
|
toast.success('个人信息已保存')
|
||||||
router.back()
|
router.back()
|
||||||
|
|||||||
@@ -1,577 +1,60 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
|
||||||
import { ArrowLeft, Play, Pause, AlertTriangle, Shield, Radio, Loader2 } from 'lucide-react'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
|
||||||
import { Button } from '@/components/ui/Button'
|
|
||||||
import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
|
|
||||||
import { Modal, ConfirmModal } from '@/components/ui/Modal'
|
|
||||||
import { ReviewSteps, getAgencyReviewSteps } from '@/components/ui/ReviewSteps'
|
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useSSE } from '@/contexts/SSEContext'
|
import { Loader2 } from 'lucide-react'
|
||||||
import type { TaskResponse, AIReviewResult } from '@/types/task'
|
|
||||||
|
|
||||||
// ==================== Mock 数据 ====================
|
/**
|
||||||
const mockTask: TaskResponse = {
|
* Redirect page: detects task type (script/video) and redirects
|
||||||
id: 'task-001',
|
* to the appropriate review detail page.
|
||||||
name: '夏日护肤推广',
|
*/
|
||||||
sequence: 1,
|
export default function ReviewRedirectPage() {
|
||||||
stage: 'script_agency_review',
|
|
||||||
project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX护肤品牌' },
|
|
||||||
agency: { id: 'ag-001', name: '优创代理' },
|
|
||||||
creator: { id: 'cr-001', name: '小美护肤' },
|
|
||||||
script_ai_score: 85,
|
|
||||||
script_ai_result: {
|
|
||||||
score: 85,
|
|
||||||
violations: [
|
|
||||||
{
|
|
||||||
type: '违禁词',
|
|
||||||
content: '效果最好',
|
|
||||||
severity: 'high',
|
|
||||||
suggestion: '建议替换为"效果显著"',
|
|
||||||
timestamp: 15.5,
|
|
||||||
source: 'speech',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: '竞品露出',
|
|
||||||
content: '疑似竞品Logo',
|
|
||||||
severity: 'high',
|
|
||||||
suggestion: '需人工确认是否为竞品露出',
|
|
||||||
timestamp: 42.0,
|
|
||||||
source: 'visual',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
soft_warnings: [
|
|
||||||
{ type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' },
|
|
||||||
],
|
|
||||||
summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认',
|
|
||||||
},
|
|
||||||
video_ai_score: 85,
|
|
||||||
video_ai_result: {
|
|
||||||
score: 85,
|
|
||||||
violations: [
|
|
||||||
{
|
|
||||||
type: '违禁词',
|
|
||||||
content: '效果最好',
|
|
||||||
severity: 'high',
|
|
||||||
suggestion: '建议替换为"效果显著"',
|
|
||||||
timestamp: 15.5,
|
|
||||||
source: 'speech',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: '竞品露出',
|
|
||||||
content: '疑似竞品Logo',
|
|
||||||
severity: 'high',
|
|
||||||
suggestion: '需人工确认是否为竞品露出',
|
|
||||||
timestamp: 42.0,
|
|
||||||
source: 'visual',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
soft_warnings: [
|
|
||||||
{ type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' },
|
|
||||||
],
|
|
||||||
summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认',
|
|
||||||
},
|
|
||||||
appeal_count: 0,
|
|
||||||
is_appeal: false,
|
|
||||||
created_at: '2026-02-03T10:30:00Z',
|
|
||||||
updated_at: '2026-02-03T10:35:00Z',
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 工具函数 ====================
|
|
||||||
|
|
||||||
function getReviewStepStatus(task: TaskResponse): string {
|
|
||||||
if (task.stage.includes('agency_review')) return 'agent_reviewing'
|
|
||||||
if (task.stage.includes('brand_review')) return 'brand_reviewing'
|
|
||||||
if (task.stage === 'completed') return 'completed'
|
|
||||||
return 'agent_reviewing'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(seconds: number): string {
|
|
||||||
const mins = Math.floor(seconds / 60)
|
|
||||||
const secs = Math.floor(seconds % 60)
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 子组件 ====================
|
|
||||||
|
|
||||||
function ReviewProgressBar({ taskStatus }: { taskStatus: string }) {
|
|
||||||
const steps = getAgencyReviewSteps(taskStatus)
|
|
||||||
const currentStep = steps.find(s => s.status === 'current')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="mb-6">
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-sm font-medium text-text-primary">审核流程</span>
|
|
||||||
<span className="text-sm text-accent-indigo font-medium">
|
|
||||||
当前:{currentStep?.label || '代理商审核'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ReviewSteps steps={steps} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RiskLevelTag({ level }: { level: string }) {
|
|
||||||
if (level === 'high') return <ErrorTag>高风险</ErrorTag>
|
|
||||||
if (level === 'medium') return <WarningTag>中风险</WarningTag>
|
|
||||||
return <SuccessTag>低风险</SuccessTag>
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 animate-pulse">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 bg-bg-elevated rounded-full" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-6 w-48 bg-bg-elevated rounded" />
|
|
||||||
<div className="h-4 w-64 bg-bg-elevated rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-16 bg-bg-elevated rounded-xl" />
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
|
||||||
<div className="lg:col-span-3 space-y-4">
|
|
||||||
<div className="h-64 bg-bg-elevated rounded-xl" />
|
|
||||||
<div className="h-20 bg-bg-elevated rounded-xl" />
|
|
||||||
</div>
|
|
||||||
<div className="lg:col-span-2 space-y-4">
|
|
||||||
<div className="h-48 bg-bg-elevated rounded-xl" />
|
|
||||||
<div className="h-32 bg-bg-elevated rounded-xl" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 主页面 ====================
|
|
||||||
|
|
||||||
export default function ReviewPage() {
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const toast = useToast()
|
|
||||||
const taskId = params.id as string
|
const taskId = params.id as string
|
||||||
const { subscribe } = useSSE()
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const [task, setTask] = useState<TaskResponse | null>(null)
|
useEffect(() => {
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
|
||||||
const [showApproveModal, setShowApproveModal] = useState(false)
|
|
||||||
const [showRejectModal, setShowRejectModal] = useState(false)
|
|
||||||
const [showForcePassModal, setShowForcePassModal] = useState(false)
|
|
||||||
const [rejectReason, setRejectReason] = useState('')
|
|
||||||
const [forcePassReason, setForcePassReason] = useState('')
|
|
||||||
const [saveAsException, setSaveAsException] = useState(false)
|
|
||||||
const [checkedViolations, setCheckedViolations] = useState<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
const loadTask = useCallback(async () => {
|
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
setTask(mockTask)
|
router.replace(`/agency/review/script/${taskId}`)
|
||||||
setLoading(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function redirect() {
|
||||||
try {
|
try {
|
||||||
const data = await api.getTask(taskId)
|
const task = await api.getTask(taskId)
|
||||||
setTask(data)
|
const isVideo = task.stage.includes('video')
|
||||||
} catch (err) {
|
const path = isVideo
|
||||||
console.error('Failed to load task:', err)
|
? `/agency/review/video/${taskId}`
|
||||||
toast.error('加载任务失败')
|
: `/agency/review/script/${taskId}`
|
||||||
} finally {
|
router.replace(path)
|
||||||
setLoading(false)
|
} catch {
|
||||||
|
setError('加载任务失败,请返回重试')
|
||||||
}
|
}
|
||||||
}, [taskId, toast])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTask()
|
|
||||||
}, [loadTask])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsub1 = subscribe('task_updated', (data: any) => {
|
|
||||||
if (data?.task_id === taskId) loadTask()
|
|
||||||
})
|
|
||||||
const unsub2 = subscribe('review_completed', (data: any) => {
|
|
||||||
if (data?.task_id === taskId) loadTask()
|
|
||||||
})
|
|
||||||
return () => { unsub1(); unsub2() }
|
|
||||||
}, [subscribe, taskId, loadTask])
|
|
||||||
|
|
||||||
if (loading || !task) return <ReviewSkeleton />
|
|
||||||
|
|
||||||
// Determine if this is script or video review
|
|
||||||
const isVideoReview = task.stage.includes('video')
|
|
||||||
const aiResult: AIReviewResult | null | undefined = isVideoReview ? task.video_ai_result : task.script_ai_result
|
|
||||||
const aiScore = isVideoReview ? task.video_ai_score : task.script_ai_score
|
|
||||||
|
|
||||||
const violations = aiResult?.violations || []
|
|
||||||
const softWarnings = aiResult?.soft_warnings || []
|
|
||||||
const aiSummary = aiResult?.summary || '暂无 AI 分析总结'
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
if (!USE_MOCK) {
|
|
||||||
if (isVideoReview) {
|
|
||||||
await api.reviewVideo(taskId, { action: 'pass' })
|
|
||||||
} else {
|
|
||||||
await api.reviewScript(taskId, { action: 'pass' })
|
|
||||||
}
|
}
|
||||||
}
|
redirect()
|
||||||
toast.success('审核已通过')
|
}, [taskId, router])
|
||||||
setShowApproveModal(false)
|
|
||||||
router.push('/agency/review')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to approve:', err)
|
|
||||||
toast.error('操作失败,请重试')
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReject = async () => {
|
|
||||||
if (!rejectReason.trim()) {
|
|
||||||
toast.error('请填写驳回原因')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
if (!USE_MOCK) {
|
|
||||||
if (isVideoReview) {
|
|
||||||
await api.reviewVideo(taskId, { action: 'reject', comment: rejectReason })
|
|
||||||
} else {
|
|
||||||
await api.reviewScript(taskId, { action: 'reject', comment: rejectReason })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast.success('已驳回')
|
|
||||||
setShowRejectModal(false)
|
|
||||||
router.push('/agency/review')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to reject:', err)
|
|
||||||
toast.error('操作失败,请重试')
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleForcePass = async () => {
|
|
||||||
if (!forcePassReason.trim()) {
|
|
||||||
toast.error('请填写强制通过原因')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSubmitting(true)
|
|
||||||
try {
|
|
||||||
if (!USE_MOCK) {
|
|
||||||
if (isVideoReview) {
|
|
||||||
await api.reviewVideo(taskId, { action: 'force_pass', comment: forcePassReason })
|
|
||||||
} else {
|
|
||||||
await api.reviewScript(taskId, { action: 'force_pass', comment: forcePassReason })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast.success('已强制通过')
|
|
||||||
setShowForcePassModal(false)
|
|
||||||
router.push('/agency/review')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to force pass:', err)
|
|
||||||
toast.error('操作失败,请重试')
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 时间线标记
|
|
||||||
const timelineMarkers = [
|
|
||||||
...violations.filter(v => v.timestamp != null).map(v => ({ time: v.timestamp!, type: 'hard' as const })),
|
|
||||||
].sort((a, b) => a.time - b.time)
|
|
||||||
|
|
||||||
const maxTime = Math.max(120, ...timelineMarkers.map(m => m.time + 10))
|
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
|
||||||
{/* 顶部导航 */}
|
<p className="text-text-secondary">{error}</p>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-bg-elevated rounded-full">
|
|
||||||
<ArrowLeft size={20} className="text-text-primary" />
|
|
||||||
</button>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h1 className="text-xl font-bold text-text-primary">{task.name}</h1>
|
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
{task.creator.name} · {task.project.brand_name || task.project.name} · {isVideoReview ? '视频审核' : '脚本审核'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{task.is_appeal && (
|
|
||||||
<span className="px-3 py-1 bg-accent-amber/20 text-accent-amber rounded-full text-sm font-medium">
|
|
||||||
申诉重审
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 申诉理由 */}
|
|
||||||
{task.is_appeal && task.appeal_reason && (
|
|
||||||
<Card className="border-accent-amber/30 bg-accent-amber/5">
|
|
||||||
<CardContent className="py-3">
|
|
||||||
<p className="text-sm text-accent-amber font-medium mb-1">申诉理由</p>
|
|
||||||
<p className="text-sm text-text-secondary">{task.appeal_reason}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 审核流程进度条 */}
|
|
||||||
<ReviewProgressBar taskStatus={getReviewStepStatus(task)} />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
|
||||||
{/* 左侧:视频/脚本播放器 (3/5) */}
|
|
||||||
<div className="lg:col-span-3 space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{isVideoReview ? (
|
|
||||||
<div className="aspect-video bg-gray-900 rounded-t-lg flex items-center justify-center relative">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
|
onClick={() => router.back()}
|
||||||
onClick={() => setIsPlaying(!isPlaying)}
|
className="text-accent-indigo hover:underline"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={32} className="text-white" /> : <Play size={32} className="text-white ml-1" />}
|
返回
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="aspect-[4/3] bg-bg-elevated rounded-t-lg flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-text-secondary">脚本预览区域</p>
|
|
||||||
<p className="text-sm text-text-tertiary mt-1">{task.script_file_name || '脚本文件'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 智能进度条(仅视频且有时间标记时显示) */}
|
|
||||||
{isVideoReview && timelineMarkers.length > 0 && (
|
|
||||||
<div className="p-4 border-t border-border-subtle">
|
|
||||||
<div className="text-sm font-medium text-text-primary mb-3">智能进度条(点击跳转)</div>
|
|
||||||
<div className="relative h-3 bg-bg-elevated rounded-full">
|
|
||||||
{timelineMarkers.map((marker, idx) => (
|
|
||||||
<button
|
|
||||||
key={idx}
|
|
||||||
type="button"
|
|
||||||
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-bg-card shadow-md cursor-pointer transition-transform hover:scale-125 ${
|
|
||||||
marker.type === 'hard' ? 'bg-accent-coral' : 'bg-orange-500'
|
|
||||||
}`}
|
|
||||||
style={{ left: `${(marker.time / maxTime) * 100}%` }}
|
|
||||||
title={`${formatTimestamp(marker.time)} - 硬性问题`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-xs text-text-tertiary mt-1">
|
|
||||||
<span>0:00</span>
|
|
||||||
<span>{formatTimestamp(maxTime)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="w-3 h-3 bg-accent-coral rounded-full" />
|
|
||||||
硬性问题
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="w-3 h-3 bg-orange-500 rounded-full" />
|
|
||||||
舆情提示
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="w-3 h-3 bg-accent-green rounded-full" />
|
|
||||||
卖点覆盖
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* AI 分析总结 */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="font-medium text-text-primary">AI 分析总结</span>
|
|
||||||
{aiScore != null && (
|
|
||||||
<span className={`text-xl font-bold ${aiScore >= 80 ? 'text-accent-green' : aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral'}`}>
|
|
||||||
{aiScore}分
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary text-sm">{aiSummary}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧:AI 检查单 (2/5) */}
|
|
||||||
<div className="lg:col-span-2 space-y-4">
|
|
||||||
{/* 硬性合规 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Shield size={16} className="text-red-500" />
|
|
||||||
硬性合规 ({violations.length})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{violations.length > 0 ? violations.map((v, idx) => {
|
|
||||||
const key = `v-${idx}`
|
|
||||||
return (
|
|
||||||
<div key={key} className={`p-3 rounded-lg border ${checkedViolations[key] ? 'bg-bg-elevated border-border-subtle' : 'bg-accent-coral/10 border-accent-coral/30'}`}>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checkedViolations[key] || false}
|
|
||||||
onChange={() => setCheckedViolations((prev) => ({ ...prev, [key]: !prev[key] }))}
|
|
||||||
className="mt-1 accent-accent-indigo"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<ErrorTag>{v.type}</ErrorTag>
|
|
||||||
{v.timestamp != null && (
|
|
||||||
<span className="text-xs text-text-tertiary">{formatTimestamp(v.timestamp)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-text-primary">「{v.content}」</p>
|
|
||||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}) : (
|
}
|
||||||
<div className="text-center py-4 text-text-tertiary text-sm">无硬性违规</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 舆情雷达 */}
|
return (
|
||||||
{softWarnings.length > 0 && (
|
<div className="flex items-center justify-center min-h-[50vh]">
|
||||||
<Card>
|
<Loader2 size={32} className="animate-spin text-accent-indigo" />
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Radio size={16} className="text-orange-500" />
|
|
||||||
舆情雷达(仅提示)
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{softWarnings.map((w, idx) => (
|
|
||||||
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<WarningTag>{w.type}</WarningTag>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-orange-400">{w.content}</p>
|
|
||||||
<p className="text-xs text-text-tertiary mt-1">软性风险仅作提示,不强制拦截</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部决策栏 */}
|
|
||||||
<Card className="sticky bottom-4 shadow-lg">
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-sm text-text-secondary">
|
|
||||||
已检查 {Object.values(checkedViolations).filter(Boolean).length}/{violations.length} 个问题
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
|
||||||
驳回
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setShowForcePassModal(true)} disabled={submitting}>
|
|
||||||
强制通过
|
|
||||||
</Button>
|
|
||||||
<Button variant="success" onClick={() => setShowApproveModal(true)} disabled={submitting}>
|
|
||||||
{submitting ? <Loader2 size={16} className="animate-spin" /> : null}
|
|
||||||
通过
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 通过确认弹窗 */}
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={showApproveModal}
|
|
||||||
onClose={() => setShowApproveModal(false)}
|
|
||||||
onConfirm={handleApprove}
|
|
||||||
title="确认通过"
|
|
||||||
message={`确定要通过此${isVideoReview ? '视频' : '脚本'}的审核吗?通过后达人将收到通知。`}
|
|
||||||
confirmText="确认通过"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 驳回弹窗 */}
|
|
||||||
<Modal isOpen={showRejectModal} onClose={() => setShowRejectModal(false)} title="驳回审核">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-text-secondary text-sm">请填写驳回原因,已勾选的问题将自动打包发送给达人。</p>
|
|
||||||
<div className="p-3 bg-bg-elevated rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-text-primary mb-2">
|
|
||||||
已选问题 ({Object.values(checkedViolations).filter(Boolean).length})
|
|
||||||
</p>
|
|
||||||
{violations.filter((_, idx) => checkedViolations[`v-${idx}`]).map((v, idx) => (
|
|
||||||
<div key={idx} className="text-sm text-text-secondary">- {v.type}: {v.content}</div>
|
|
||||||
))}
|
|
||||||
{Object.values(checkedViolations).filter(Boolean).length === 0 && (
|
|
||||||
<div className="text-sm text-text-tertiary">未选择任何问题</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">补充说明</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
|
||||||
placeholder="请详细说明驳回原因..."
|
|
||||||
value={rejectReason}
|
|
||||||
onChange={(e) => setRejectReason(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<Button variant="ghost" onClick={() => setShowRejectModal(false)} disabled={submitting}>取消</Button>
|
|
||||||
<Button variant="danger" onClick={handleReject} disabled={submitting}>
|
|
||||||
{submitting && <Loader2 size={16} className="animate-spin" />}
|
|
||||||
确认驳回
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 强制通过弹窗 */}
|
|
||||||
<Modal isOpen={showForcePassModal} onClose={() => setShowForcePassModal(false)} title="强制通过">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
|
||||||
<p className="text-sm text-yellow-400">
|
|
||||||
<AlertTriangle size={14} className="inline mr-1" />
|
|
||||||
强制通过将跳过所有问题检测,操作将被记录
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">放行原因(必填)</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full h-24 p-3 border border-border-subtle rounded-lg resize-none bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
|
|
||||||
placeholder="例如:达人玩的新梗,品牌方认可"
|
|
||||||
value={forcePassReason}
|
|
||||||
onChange={(e) => setForcePassReason(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={saveAsException}
|
|
||||||
onChange={(e) => setSaveAsException(e.target.checked)}
|
|
||||||
className="rounded accent-accent-indigo"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-text-secondary">保存为特例(需品牌方确认后生效)</span>
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<Button variant="ghost" onClick={() => setShowForcePassModal(false)} disabled={submitting}>取消</Button>
|
|
||||||
<Button onClick={handleForcePass} disabled={submitting}>
|
|
||||||
{submitting && <Loader2 size={16} className="animate-spin" />}
|
|
||||||
确认强制通过
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import { USE_MOCK } from '@/contexts/AuthContext'
|
|||||||
import { useSSE } from '@/contexts/SSEContext'
|
import { useSSE } from '@/contexts/SSEContext'
|
||||||
import type { TaskResponse } from '@/types/task'
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
|
function platformLabel(id?: string | null): string {
|
||||||
|
if (!id) return ''
|
||||||
|
return getPlatformInfo(id)?.name || id
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Mock 数据 ====================
|
// ==================== Mock 数据 ====================
|
||||||
const mockScriptTasks: TaskResponse[] = [
|
const mockScriptTasks: TaskResponse[] = [
|
||||||
{
|
{
|
||||||
@@ -151,7 +156,10 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrev
|
|||||||
<div className="rounded-xl bg-bg-elevated overflow-hidden">
|
<div className="rounded-xl bg-bg-elevated overflow-hidden">
|
||||||
{/* 顶部条 */}
|
{/* 顶部条 */}
|
||||||
<div className="px-4 py-1.5 bg-accent-indigo/10 border-b border-accent-indigo/20 flex items-center gap-1.5">
|
<div className="px-4 py-1.5 bg-accent-indigo/10 border-b border-accent-indigo/20 flex items-center gap-1.5">
|
||||||
<span className="text-xs font-medium text-accent-indigo">{task.project.brand_name || task.project.name}</span>
|
<span className="text-xs font-medium text-accent-indigo">{task.project.brand_name || ''}</span>
|
||||||
|
{task.project.platform && (
|
||||||
|
<span className="text-xs text-text-tertiary">· {platformLabel(task.project.platform)}</span>
|
||||||
|
)}
|
||||||
{task.is_appeal && (
|
{task.is_appeal && (
|
||||||
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
|
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
|
||||||
<MessageSquareWarning size={12} />
|
<MessageSquareWarning size={12} />
|
||||||
@@ -161,12 +169,13 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrev
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
|
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${riskConfig.color}`} />
|
||||||
<span className="font-medium text-text-primary">{task.creator.name} · {task.name}</span>
|
<span className="font-medium text-text-primary truncate">{task.project.name} · {task.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
|
<span className={`text-xs flex-shrink-0 ${riskConfig.textColor}`}>{riskConfig.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-text-secondary mb-3">达人:{task.creator.name}</p>
|
||||||
|
|
||||||
{task.is_appeal && task.appeal_reason && (
|
{task.is_appeal && task.appeal_reason && (
|
||||||
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
|
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
|
||||||
@@ -195,7 +204,7 @@ function ScriptTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrev
|
|||||||
<Clock size={12} />
|
<Clock size={12} />
|
||||||
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
<Link href={`/agency/review/${task.id}`}>
|
<Link href={`/agency/review/script/${task.id}`}>
|
||||||
<Button size="sm" className={`${
|
<Button size="sm" className={`${
|
||||||
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
|
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
|
||||||
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
|
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
|
||||||
@@ -227,7 +236,10 @@ function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrevi
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-bg-elevated overflow-hidden">
|
<div className="rounded-xl bg-bg-elevated overflow-hidden">
|
||||||
<div className="px-4 py-1.5 bg-purple-500/10 border-b border-purple-500/20 flex items-center gap-1.5">
|
<div className="px-4 py-1.5 bg-purple-500/10 border-b border-purple-500/20 flex items-center gap-1.5">
|
||||||
<span className="text-xs font-medium text-purple-400">{task.project.brand_name || task.project.name}</span>
|
<span className="text-xs font-medium text-purple-400">{task.project.brand_name || ''}</span>
|
||||||
|
{task.project.platform && (
|
||||||
|
<span className="text-xs text-text-tertiary">· {platformLabel(task.project.platform)}</span>
|
||||||
|
)}
|
||||||
{task.is_appeal && (
|
{task.is_appeal && (
|
||||||
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
|
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
|
||||||
<MessageSquareWarning size={12} />
|
<MessageSquareWarning size={12} />
|
||||||
@@ -237,12 +249,13 @@ function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrevi
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
|
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${riskConfig.color}`} />
|
||||||
<span className="font-medium text-text-primary">{task.creator.name} · {task.name}</span>
|
<span className="font-medium text-text-primary truncate">{task.project.name} · {task.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
|
<span className={`text-xs flex-shrink-0 ${riskConfig.textColor}`}>{riskConfig.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-text-secondary mb-3">达人:{task.creator.name}</p>
|
||||||
|
|
||||||
{task.is_appeal && task.appeal_reason && (
|
{task.is_appeal && task.appeal_reason && (
|
||||||
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
|
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
|
||||||
@@ -274,7 +287,7 @@ function VideoTaskCard({ task, onPreview, toast }: { task: TaskResponse; onPrevi
|
|||||||
<Clock size={12} />
|
<Clock size={12} />
|
||||||
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
{new Date(task.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
<Link href={`/agency/review/${task.id}`}>
|
<Link href={`/agency/review/video/${task.id}`}>
|
||||||
<Button size="sm" className={`${
|
<Button size="sm" className={`${
|
||||||
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
|
riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
|
||||||
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
|
riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
import { FilePreview, FileInfoCard, FilePreviewModal, type FileInfo } from '@/components/ui/FilePreview'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
import type { TaskResponse } from '@/types/task'
|
import type { TaskResponse } from '@/types/task'
|
||||||
|
|
||||||
// 模拟脚本任务数据
|
// 模拟脚本任务数据
|
||||||
@@ -81,6 +82,8 @@ function mapTaskToViewModel(task: TaskResponse) {
|
|||||||
title: task.name,
|
title: task.name,
|
||||||
creatorName: task.creator?.name || '未知达人',
|
creatorName: task.creator?.name || '未知达人',
|
||||||
projectName: task.project?.name || '未知项目',
|
projectName: task.project?.name || '未知项目',
|
||||||
|
brandName: task.project?.brand_name || '',
|
||||||
|
platform: task.project?.platform || '',
|
||||||
submittedAt: task.script_uploaded_at || task.created_at,
|
submittedAt: task.script_uploaded_at || task.created_at,
|
||||||
aiScore: task.script_ai_score ?? 0,
|
aiScore: task.script_ai_score ?? 0,
|
||||||
status: task.stage,
|
status: task.stage,
|
||||||
@@ -107,12 +110,26 @@ function mapTaskToViewModel(task: TaskResponse) {
|
|||||||
content: v.content,
|
content: v.content,
|
||||||
suggestion: v.suggestion,
|
suggestion: v.suggestion,
|
||||||
severity: v.severity,
|
severity: v.severity,
|
||||||
|
dimension: v.dimension,
|
||||||
})),
|
})),
|
||||||
complianceChecks: (task.script_ai_result?.soft_warnings || []).map((w) => ({
|
complianceChecks: (task.script_ai_result?.soft_warnings || []).map((w: any) => {
|
||||||
item: w.type,
|
const codeLabels: Record<string, string> = {
|
||||||
|
missing_selling_points: '卖点缺失',
|
||||||
|
tone_mismatch: '语气不符',
|
||||||
|
length_warning: '时长提示',
|
||||||
|
style_warning: '风格提示',
|
||||||
|
sensitive_topic: '敏感话题',
|
||||||
|
audience_mismatch: '受众偏差',
|
||||||
|
}
|
||||||
|
const rawLabel = w.type || w.code || '提示'
|
||||||
|
return {
|
||||||
|
item: codeLabels[rawLabel] || rawLabel,
|
||||||
passed: false,
|
passed: false,
|
||||||
note: w.content,
|
note: w.content || w.message || '',
|
||||||
})),
|
}
|
||||||
|
}),
|
||||||
|
dimensions: task.script_ai_result?.dimensions,
|
||||||
|
sellingPointMatches: task.script_ai_result?.selling_point_matches || [],
|
||||||
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
|
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
|
||||||
},
|
},
|
||||||
aiSummary: task.script_ai_result?.summary || '',
|
aiSummary: task.script_ai_result?.summary || '',
|
||||||
@@ -183,6 +200,7 @@ export default function AgencyScriptReviewPage() {
|
|||||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||||
const [task, setTask] = useState<ScriptTaskViewModel>(mockScriptTask as unknown as ScriptTaskViewModel)
|
const [task, setTask] = useState<ScriptTaskViewModel>(mockScriptTask as unknown as ScriptTaskViewModel)
|
||||||
|
|
||||||
|
|
||||||
const loadTask = useCallback(async () => {
|
const loadTask = useCallback(async () => {
|
||||||
if (USE_MOCK) return
|
if (USE_MOCK) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -294,10 +312,9 @@ export default function AgencyScriptReviewPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
|
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
|
||||||
<span className="flex items-center gap-1">
|
<span>{task.creatorName}</span>
|
||||||
<User size={14} />
|
{task.brandName && <span>{task.brandName}</span>}
|
||||||
{task.creatorName}
|
{task.platform && <span>{getPlatformInfo(task.platform)?.name || task.platform}</span>}
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock size={14} />
|
<Clock size={14} />
|
||||||
{task.submittedAt}
|
{task.submittedAt}
|
||||||
@@ -368,8 +385,7 @@ export default function AgencyScriptReviewPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FileText size={18} className="text-accent-indigo" />
|
<FileText size={18} className="text-accent-indigo" />
|
||||||
AI 解析内容
|
AI 审核分析
|
||||||
<span className="text-xs font-normal text-text-tertiary ml-2">(AI 自动提取的结构化内容)</span>
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -378,23 +394,41 @@ export default function AgencyScriptReviewPage() {
|
|||||||
<div className="text-xs text-accent-indigo font-medium mb-2">AI 总结</div>
|
<div className="text-xs text-accent-indigo font-medium mb-2">AI 总结</div>
|
||||||
<p className="text-text-primary">{task.aiSummary}</p>
|
<p className="text-text-primary">{task.aiSummary}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
|
<p className="text-sm text-text-tertiary text-center py-4">暂无 AI 分析总结</p>
|
||||||
|
)}
|
||||||
|
{task.aiAnalysis.violations.length > 0 && (
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-accent-indigo font-medium mb-2">开场白</div>
|
<div className="text-xs text-accent-coral font-medium mb-2">发现问题 ({task.aiAnalysis.violations.length})</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.opening || '(无内容)'}</p>
|
<div className="space-y-2">
|
||||||
|
{task.aiAnalysis.violations.map((v) => (
|
||||||
|
<div key={v.id} className="text-sm">
|
||||||
|
<span className="text-accent-coral font-medium">[{v.type}]</span>
|
||||||
|
<span className="text-text-primary ml-1">{v.content}</span>
|
||||||
|
<p className="text-xs text-accent-indigo mt-0.5">{v.suggestion}</p>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.aiAnalysis.sellingPointMatches.length > 0 && (
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="text-xs text-purple-400 font-medium mb-2">产品介绍</div>
|
<div className="text-xs text-accent-green font-medium mb-2">卖点匹配概览</div>
|
||||||
<p className="text-text-primary">{task.scriptContent.productIntro || '(无内容)'}</p>
|
<div className="space-y-1">
|
||||||
|
{task.aiAnalysis.sellingPointMatches.map((sp: { content: string; priority: string; matched: boolean; evidence?: string }, idx: number) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||||
|
{sp.matched ? <CheckCircle size={14} className="text-accent-green flex-shrink-0" /> : <XCircle size={14} className="text-accent-coral flex-shrink-0" />}
|
||||||
|
<span className="text-text-primary">{sp.content}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
|
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||||
|
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||||
|
'bg-bg-page text-text-tertiary'
|
||||||
|
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
))}
|
||||||
<div className="text-xs text-orange-400 font-medium mb-2">使用演示</div>
|
|
||||||
<p className="text-text-primary">{task.scriptContent.demo || '(无内容)'}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
|
||||||
<div className="text-xs text-accent-green font-medium mb-2">结尾引导</div>
|
|
||||||
<p className="text-text-primary">{task.scriptContent.closing || '(无内容)'}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -414,6 +448,34 @@ export default function AgencyScriptReviewPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 维度评分 */}
|
||||||
|
{task.aiAnalysis.dimensions && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Shield size={16} className="text-accent-indigo" />
|
||||||
|
维度评分
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
|
||||||
|
const dim = (task.aiAnalysis.dimensions as unknown as Record<string, { score: number; passed: boolean; issue_count: number }>)?.[key]
|
||||||
|
if (!dim) return null
|
||||||
|
const label = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[key]
|
||||||
|
return (
|
||||||
|
<div key={key} className={`flex items-center justify-between p-2 rounded-lg ${dim.passed ? 'bg-accent-green/5' : 'bg-accent-coral/5'}`}>
|
||||||
|
<span className="text-sm text-text-primary">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm font-bold ${dim.passed ? 'text-accent-green' : 'text-accent-coral'}`}>{dim.score}</span>
|
||||||
|
{dim.passed ? <CheckCircle size={14} className="text-accent-green" /> : <XCircle size={14} className="text-accent-coral" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 违规检测 */}
|
{/* 违规检测 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
@@ -427,6 +489,7 @@ export default function AgencyScriptReviewPage() {
|
|||||||
<div key={v.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
<div key={v.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<WarningTag>{v.type}</WarningTag>
|
<WarningTag>{v.type}</WarningTag>
|
||||||
|
{v.dimension && <span className="text-xs text-text-tertiary">{{ legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[v.dimension as string]}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-primary">{v.content}</p>
|
<p className="text-sm text-text-primary">{v.content}</p>
|
||||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||||
@@ -438,53 +501,58 @@ export default function AgencyScriptReviewPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 合规检查 */}
|
{/* 舆情提示 */}
|
||||||
|
{task.aiAnalysis.complianceChecks.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Shield size={16} className="text-accent-indigo" />
|
<AlertTriangle size={16} className="text-orange-500" />
|
||||||
合规检查
|
舆情提示(仅参考)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{task.aiAnalysis.complianceChecks.map((check, idx) => (
|
{task.aiAnalysis.complianceChecks.map((check, idx) => (
|
||||||
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||||
{check.passed ? (
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
|
<WarningTag>{check.item}</WarningTag>
|
||||||
) : (
|
|
||||||
<XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<span className="text-sm text-text-primary">{check.item}</span>
|
|
||||||
{check.note && (
|
|
||||||
<p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{check.note && (
|
||||||
|
<p className="text-sm text-text-secondary">{check.note}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">软性风险仅作提示,不影响审核结果</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 卖点覆盖 */}
|
{/* 卖点匹配 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<CheckCircle size={16} className="text-accent-green" />
|
<CheckCircle size={16} className="text-accent-green" />
|
||||||
卖点覆盖
|
卖点匹配
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{task.aiAnalysis.sellingPoints.map((sp, idx) => (
|
{task.aiAnalysis.sellingPointMatches && task.aiAnalysis.sellingPointMatches.length > 0 ? (
|
||||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
|
task.aiAnalysis.sellingPointMatches.map((sp: { content: string; priority: string; matched: boolean; evidence?: string }, idx: number) => (
|
||||||
{sp.covered ? (
|
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||||
<CheckCircle size={16} className="text-accent-green" />
|
{sp.matched ? <CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />}
|
||||||
) : (
|
<div className="flex-1">
|
||||||
<XCircle size={16} className="text-accent-coral" />
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<span className="text-sm text-text-primary">{sp.content}</span>
|
||||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
<span className={`px-1.5 py-0.5 text-xs rounded ${
|
||||||
|
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||||
|
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||||
|
'bg-bg-page text-text-tertiary'
|
||||||
|
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
|
||||||
{task.aiAnalysis.sellingPoints.length === 0 && (
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
<p className="text-sm text-text-tertiary text-center py-4">暂无卖点数据</p>
|
<p className="text-sm text-text-tertiary text-center py-4">暂无卖点数据</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -497,7 +565,9 @@ export default function AgencyScriptReviewPage() {
|
|||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-text-secondary">
|
<div className="text-sm text-text-secondary">
|
||||||
项目:{task.projectName}
|
{task.brandName && <span>{task.brandName} · </span>}
|
||||||
|
{task.projectName}
|
||||||
|
{task.platform && <span> · {getPlatformInfo(task.platform)?.name || task.platform}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
<Button variant="danger" onClick={() => setShowRejectModal(true)} disabled={submitting}>
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export default function AgencyVideoReviewPage() {
|
|||||||
const [videoError, setVideoError] = useState(false)
|
const [videoError, setVideoError] = useState(false)
|
||||||
const [task, setTask] = useState<VideoTaskViewModel>(mockVideoTask as unknown as VideoTaskViewModel)
|
const [task, setTask] = useState<VideoTaskViewModel>(mockVideoTask as unknown as VideoTaskViewModel)
|
||||||
|
|
||||||
|
|
||||||
const loadTask = useCallback(async () => {
|
const loadTask = useCallback(async () => {
|
||||||
if (USE_MOCK) return
|
if (USE_MOCK) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertTriangle
|
AlertTriangle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
export default function AgencyAccountSettingsPage() {
|
export default function AgencyAccountSettingsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -53,10 +55,25 @@ export default function AgencyAccountSettingsPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
if (USE_MOCK) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
toast.success('密码修改成功')
|
toast.success('密码修改成功')
|
||||||
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.changePassword({
|
||||||
|
old_password: passwordForm.oldPassword,
|
||||||
|
new_password: passwordForm.newPassword,
|
||||||
|
})
|
||||||
|
toast.success('密码修改成功')
|
||||||
|
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || '密码修改失败')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@@ -117,7 +119,13 @@ export default function AgencyNotificationSettingsPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
if (USE_MOCK) {
|
||||||
|
// Mock 模式:模拟保存延迟
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
// TODO: 后端通知设置 API 待实现,暂时使用 mock 行为
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
}
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
toast.success('通知设置已保存')
|
toast.success('通知设置已保存')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
import type { TaskResponse, TaskStage } from '@/types/task'
|
import type { TaskResponse, TaskStage } from '@/types/task'
|
||||||
|
|
||||||
|
function getPlatformLabel(platformId: string): string {
|
||||||
|
return getPlatformInfo(platformId)?.name || platformId
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 本地视图模型 ====================
|
// ==================== 本地视图模型 ====================
|
||||||
interface TaskViewModel {
|
interface TaskViewModel {
|
||||||
id: string
|
id: string
|
||||||
@@ -225,7 +230,7 @@ function mapTaskResponseToViewModel(task: TaskResponse): TaskViewModel {
|
|||||||
videoTitle: task.name,
|
videoTitle: task.name,
|
||||||
creatorName: task.creator?.name || '未知达人',
|
creatorName: task.creator?.name || '未知达人',
|
||||||
brandName: task.project?.brand_name || '未知品牌',
|
brandName: task.project?.brand_name || '未知品牌',
|
||||||
platform: '小红书', // 后端暂无 platform 字段
|
platform: task.project?.platform ? getPlatformLabel(task.project.platform) : '未知平台',
|
||||||
status,
|
status,
|
||||||
aiScore,
|
aiScore,
|
||||||
finalScore,
|
finalScore,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Card, CardContent } from '@/components/ui/Card'
|
import { Card, CardContent } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
@@ -69,6 +69,30 @@ export default function AgenciesManagePage() {
|
|||||||
|
|
||||||
// 操作菜单状态
|
// 操作菜单状态
|
||||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||||
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleToggleMenu = (agencyId: string, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (openMenuId === agencyId) {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
setMenuPos({ top: rect.bottom + 4, left: rect.right - 160 }) // 160 = menu width
|
||||||
|
setOpenMenuId(agencyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭菜单
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openMenuId) return
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [openMenuId])
|
||||||
|
|
||||||
// 删除确认弹窗状态
|
// 删除确认弹窗状态
|
||||||
const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null })
|
const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null })
|
||||||
@@ -351,35 +375,13 @@ export default function AgenciesManagePage() {
|
|||||||
<StatusTag forcePass={agency.force_pass_enabled} />
|
<StatusTag forcePass={agency.force_pass_enabled} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="relative">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setOpenMenuId(openMenuId === agency.id ? null : agency.id)}
|
onClick={(e) => handleToggleMenu(agency.id, e)}
|
||||||
>
|
>
|
||||||
<MoreVertical size={16} />
|
<MoreVertical size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
{openMenuId === agency.id && (
|
|
||||||
<div className="absolute right-0 top-full mt-1 w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-10 overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOpenAssign(agency)}
|
|
||||||
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<FolderPlus size={14} className="text-text-secondary" />
|
|
||||||
分配到项目
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleOpenDelete(agency)}
|
|
||||||
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
移除代理商
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -396,6 +398,38 @@ export default function AgenciesManagePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 操作菜单(fixed 定位,不受 overflow 裁剪) */}
|
||||||
|
{openMenuId && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed w-40 bg-bg-card rounded-xl shadow-lg border border-border-subtle z-50 overflow-hidden"
|
||||||
|
style={{ top: menuPos.top, left: menuPos.left }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const agency = agencies.find(a => a.id === openMenuId)
|
||||||
|
if (agency) handleOpenAssign(agency)
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FolderPlus size={14} className="text-text-secondary" />
|
||||||
|
分配到项目
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const agency = agencies.find(a => a.id === openMenuId)
|
||||||
|
if (agency) handleOpenDelete(agency)
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2.5 text-left text-sm text-accent-coral hover:bg-accent-coral/10 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
移除代理商
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 邀请代理商弹窗 */}
|
{/* 邀请代理商弹窗 */}
|
||||||
<Modal isOpen={showInviteModal} onClose={handleCloseInviteModal} title="邀请代理商">
|
<Modal isOpen={showInviteModal} onClose={handleCloseInviteModal} title="邀请代理商">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -539,13 +573,6 @@ export default function AgenciesManagePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 点击其他地方关闭菜单 */}
|
|
||||||
{openMenuId && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-0"
|
|
||||||
onClick={() => setOpenMenuId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,21 +34,67 @@ const providerOptions: { value: AIProvider | string; label: string }[] = [
|
|||||||
{ value: 'moonshot', label: 'Moonshot' },
|
{ value: 'moonshot', label: 'Moonshot' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Mock 可用模型列表
|
// 预设可用模型列表
|
||||||
const mockModels: Record<string, ModelInfo[]> = {
|
const mockModels: Record<string, ModelInfo[]> = {
|
||||||
text: [
|
text: [
|
||||||
|
// Anthropic Claude
|
||||||
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||||||
|
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||||
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
||||||
|
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
|
||||||
|
// OpenAI
|
||||||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
|
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
|
||||||
|
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
|
||||||
|
{ id: 'o1', name: 'o1' },
|
||||||
|
{ id: 'o3-mini', name: 'o3-mini' },
|
||||||
|
// Google
|
||||||
|
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
|
||||||
|
{ id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' },
|
||||||
|
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
|
||||||
|
// DeepSeek
|
||||||
|
{ id: 'deepseek-chat', name: 'DeepSeek V3' },
|
||||||
|
{ id: 'deepseek-reasoner', name: 'DeepSeek R1' },
|
||||||
|
// 通义千问
|
||||||
|
{ id: 'qwen-max', name: '通义千问 Max' },
|
||||||
|
{ id: 'qwen-plus', name: '通义千问 Plus' },
|
||||||
|
{ id: 'qwen-turbo', name: '通义千问 Turbo' },
|
||||||
|
// 豆包
|
||||||
|
{ id: 'doubao-pro-256k', name: '豆包 Pro 256K' },
|
||||||
|
{ id: 'doubao-pro-32k', name: '豆包 Pro 32K' },
|
||||||
|
// 智谱
|
||||||
|
{ id: 'glm-4-plus', name: 'GLM-4 Plus' },
|
||||||
|
{ id: 'glm-4', name: 'GLM-4' },
|
||||||
|
// Moonshot
|
||||||
|
{ id: 'moonshot-v1-128k', name: 'Moonshot V1 128K' },
|
||||||
|
{ id: 'moonshot-v1-32k', name: 'Moonshot V1 32K' },
|
||||||
],
|
],
|
||||||
vision: [
|
vision: [
|
||||||
|
// Anthropic Claude (原生多模态)
|
||||||
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||||||
|
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||||
|
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
|
||||||
|
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
|
||||||
|
// OpenAI
|
||||||
{ id: 'gpt-4o', name: 'GPT-4o' },
|
{ id: 'gpt-4o', name: 'GPT-4o' },
|
||||||
|
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
|
||||||
|
// Google
|
||||||
|
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
|
||||||
|
{ id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' },
|
||||||
|
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
|
||||||
|
// 通义千问 VL
|
||||||
|
{ id: 'qwen-vl-max', name: '通义千问 VL Max' },
|
||||||
|
{ id: 'qwen-vl-plus', name: '通义千问 VL Plus' },
|
||||||
|
// 智谱
|
||||||
|
{ id: 'glm-4v-plus', name: 'GLM-4V Plus' },
|
||||||
|
{ id: 'glm-4v', name: 'GLM-4V' },
|
||||||
],
|
],
|
||||||
audio: [
|
audio: [
|
||||||
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
|
{ id: 'whisper-large-v3', name: 'Whisper Large V3' },
|
||||||
|
{ id: 'whisper-large-v3-turbo', name: 'Whisper Large V3 Turbo' },
|
||||||
{ id: 'whisper-medium', name: 'Whisper Medium' },
|
{ id: 'whisper-medium', name: 'Whisper Medium' },
|
||||||
|
{ id: 'sensevoice-v1', name: 'SenseVoice V1 (阿里)' },
|
||||||
|
{ id: 'paraformer-realtime-v2', name: 'Paraformer V2 (阿里)' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +129,9 @@ export default function AIConfigPage() {
|
|||||||
const [maxTokens, setMaxTokens] = useState(2000)
|
const [maxTokens, setMaxTokens] = useState(2000)
|
||||||
|
|
||||||
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo[]>>(mockModels)
|
const [availableModels, setAvailableModels] = useState<Record<string, ModelInfo[]>>(mockModels)
|
||||||
|
const [customLlmModel, setCustomLlmModel] = useState('')
|
||||||
|
const [customVisionModel, setCustomVisionModel] = useState('')
|
||||||
|
const [customAsrModel, setCustomAsrModel] = useState('')
|
||||||
|
|
||||||
const [testResults, setTestResults] = useState<Record<string, { status: TestStatus; latency?: number; error?: string }>>({
|
const [testResults, setTestResults] = useState<Record<string, { status: TestStatus; latency?: number; error?: string }>>({
|
||||||
text: { status: 'idle' },
|
text: { status: 'idle' },
|
||||||
@@ -101,17 +150,41 @@ export default function AIConfigPage() {
|
|||||||
setBaseUrl(config.base_url)
|
setBaseUrl(config.base_url)
|
||||||
setApiKey('') // API key is masked, don't fill it
|
setApiKey('') // API key is masked, don't fill it
|
||||||
setIsConfigured(config.is_configured)
|
setIsConfigured(config.is_configured)
|
||||||
|
const models = availableModels
|
||||||
|
// 如果后端返回的模型不在预设列表中,设为自定义
|
||||||
|
if (config.models.text && !(models.text || []).some(m => m.id === config.models.text)) {
|
||||||
|
setLlmModel('__custom__')
|
||||||
|
setCustomLlmModel(config.models.text)
|
||||||
|
} else {
|
||||||
setLlmModel(config.models.text)
|
setLlmModel(config.models.text)
|
||||||
|
}
|
||||||
|
if (config.models.vision && !(models.vision || []).some(m => m.id === config.models.vision)) {
|
||||||
|
setVisionModel('__custom__')
|
||||||
|
setCustomVisionModel(config.models.vision)
|
||||||
|
} else {
|
||||||
setVisionModel(config.models.vision)
|
setVisionModel(config.models.vision)
|
||||||
|
}
|
||||||
|
if (config.models.audio && !(models.audio || []).some(m => m.id === config.models.audio)) {
|
||||||
|
setAsrModel('__custom__')
|
||||||
|
setCustomAsrModel(config.models.audio)
|
||||||
|
} else {
|
||||||
setAsrModel(config.models.audio)
|
setAsrModel(config.models.audio)
|
||||||
|
}
|
||||||
setTemperature(config.parameters.temperature)
|
setTemperature(config.parameters.temperature)
|
||||||
setMaxTokens(config.parameters.max_tokens)
|
setMaxTokens(config.parameters.max_tokens)
|
||||||
if (config.available_models && Object.keys(config.available_models).length > 0) {
|
if (config.available_models && Object.keys(config.available_models).length > 0) {
|
||||||
setAvailableModels(config.available_models)
|
setAvailableModels(config.available_models)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
|
// 后端 404 返回 "AI 服务未配置" → Axios 拦截器转为 Error(message)
|
||||||
|
// 这是正常的"尚未配置"状态,不弹错误
|
||||||
|
const msg = err?.message || ''
|
||||||
|
if (msg.includes('未配置')) {
|
||||||
|
setIsConfigured(false)
|
||||||
|
} else {
|
||||||
console.error('Failed to load AI config:', err)
|
console.error('Failed to load AI config:', err)
|
||||||
toast.error('加载 AI 配置失败')
|
toast.error('加载 AI 配置失败')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -119,6 +192,10 @@ export default function AIConfigPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadConfig() }, [loadConfig])
|
useEffect(() => { loadConfig() }, [loadConfig])
|
||||||
|
|
||||||
|
// 获取实际使用的模型 ID(自定义时用输入值)
|
||||||
|
const getActualModel = (selected: string, custom: string) =>
|
||||||
|
selected === '__custom__' ? custom : selected
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
setTestResults({
|
setTestResults({
|
||||||
text: { status: 'testing' },
|
text: { status: 'testing' },
|
||||||
@@ -141,7 +218,7 @@ export default function AIConfigPage() {
|
|||||||
provider: provider as AIProvider,
|
provider: provider as AIProvider,
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
api_key: apiKey || '***', // use existing key if not changed
|
api_key: apiKey || '***', // use existing key if not changed
|
||||||
models: { text: llmModel, vision: visionModel, audio: asrModel },
|
models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) },
|
||||||
})
|
})
|
||||||
const newResults: Record<string, { status: TestStatus; latency?: number; error?: string }> = {}
|
const newResults: Record<string, { status: TestStatus; latency?: number; error?: string }> = {}
|
||||||
for (const [key, r] of Object.entries(result.results)) {
|
for (const [key, r] of Object.entries(result.results)) {
|
||||||
@@ -177,7 +254,7 @@ export default function AIConfigPage() {
|
|||||||
provider: provider as AIProvider,
|
provider: provider as AIProvider,
|
||||||
base_url: baseUrl,
|
base_url: baseUrl,
|
||||||
api_key: apiKey || '***',
|
api_key: apiKey || '***',
|
||||||
models: { text: llmModel, vision: visionModel, audio: asrModel },
|
models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) },
|
||||||
parameters: { temperature, max_tokens: maxTokens },
|
parameters: { temperature, max_tokens: maxTokens },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -297,32 +374,52 @@ export default function AIConfigPage() {
|
|||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
value={llmModel}
|
value={llmModel}
|
||||||
onChange={(e) => setLlmModel(e.target.value)}
|
onChange={(e) => { setLlmModel(e.target.value); if (e.target.value !== '__custom__') setCustomLlmModel('') }}
|
||||||
>
|
>
|
||||||
{(availableModels.text || []).map(model => (
|
{(availableModels.text || []).map(model => (
|
||||||
<option key={model.id} value={model.id}>{model.name}</option>
|
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
|
||||||
))}
|
))}
|
||||||
|
<option value="__custom__">自定义模型...</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-tertiary mt-2">用于 Brief 解析、语义分析、报告生成</p>
|
{llmModel === '__custom__' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
|
value={customLlmModel}
|
||||||
|
onChange={(e) => setCustomLlmModel(e.target.value)}
|
||||||
|
placeholder="输入模型 ID,如 deepseek-chat"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-text-tertiary mt-2">用于 Brief 解析、脚本语义审核、卖点匹配分析</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 视频分析模型 */}
|
{/* 视觉理解模型 */}
|
||||||
<div className="p-4 bg-bg-elevated rounded-lg">
|
<div className="p-4 bg-bg-elevated rounded-lg">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Eye size={16} className="text-accent-green" />
|
<Eye size={16} className="text-accent-green" />
|
||||||
<span className="font-medium text-text-primary">视频分析模型 (Vision)</span>
|
<span className="font-medium text-text-primary">视觉理解模型 (Vision)</span>
|
||||||
{getTestStatusIcon('vision')}
|
{getTestStatusIcon('vision')}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
value={visionModel}
|
value={visionModel}
|
||||||
onChange={(e) => setVisionModel(e.target.value)}
|
onChange={(e) => { setVisionModel(e.target.value); if (e.target.value !== '__custom__') setCustomVisionModel('') }}
|
||||||
>
|
>
|
||||||
{(availableModels.vision || []).map(model => (
|
{(availableModels.vision || []).map(model => (
|
||||||
<option key={model.id} value={model.id}>{model.name}</option>
|
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
|
||||||
))}
|
))}
|
||||||
|
<option value="__custom__">自定义模型...</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-tertiary mt-2">用于画面语义分析、场景/风险识别</p>
|
{visionModel === '__custom__' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
|
value={customVisionModel}
|
||||||
|
onChange={(e) => setCustomVisionModel(e.target.value)}
|
||||||
|
placeholder="输入模型 ID,如 gpt-4o"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-text-tertiary mt-2">用于脚本文档中的图片审核(竞品 logo、违规画面识别)及视频帧分析</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 音频解析模型 */}
|
{/* 音频解析模型 */}
|
||||||
@@ -335,12 +432,22 @@ export default function AIConfigPage() {
|
|||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
className="w-full px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
value={asrModel}
|
value={asrModel}
|
||||||
onChange={(e) => setAsrModel(e.target.value)}
|
onChange={(e) => { setAsrModel(e.target.value); if (e.target.value !== '__custom__') setCustomAsrModel('') }}
|
||||||
>
|
>
|
||||||
{(availableModels.audio || []).map(model => (
|
{(availableModels.audio || []).map(model => (
|
||||||
<option key={model.id} value={model.id}>{model.name}</option>
|
<option key={model.id} value={model.id}>{model.name} ({model.id})</option>
|
||||||
))}
|
))}
|
||||||
|
<option value="__custom__">自定义模型...</option>
|
||||||
</select>
|
</select>
|
||||||
|
{asrModel === '__custom__' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full mt-2 px-3 py-2 border border-border-subtle rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-indigo bg-bg-card text-text-primary"
|
||||||
|
value={customAsrModel}
|
||||||
|
onChange={(e) => setCustomAsrModel(e.target.value)}
|
||||||
|
placeholder="输入模型 ID,如 whisper-large-v3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p className="text-xs text-text-tertiary mt-2">用于语音转文字、口播内容提取</p>
|
<p className="text-xs text-text-tertiary mt-2">用于语音转文字、口播内容提取</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import { Card, CardContent } from '@/components/ui/Card'
|
import { Card, CardContent } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +45,10 @@ type MessageType =
|
|||||||
| 'brief_config_updated' // 代理商更新了Brief配置
|
| 'brief_config_updated' // 代理商更新了Brief配置
|
||||||
| 'batch_review_done' // 批量审核完成
|
| 'batch_review_done' // 批量审核完成
|
||||||
| 'system_notice' // 系统通知
|
| 'system_notice' // 系统通知
|
||||||
|
| 'new_task' // 新任务分配
|
||||||
|
| 'pass' // 审核通过
|
||||||
|
| 'reject' // 审核驳回
|
||||||
|
| 'approve' // 审核批准
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
id: string
|
id: string
|
||||||
@@ -78,6 +84,10 @@ const messageConfig: Record<MessageType, {
|
|||||||
brief_config_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
brief_config_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
||||||
batch_review_done: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
batch_review_done: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||||||
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
|
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
|
||||||
|
new_task: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
||||||
|
pass: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||||||
|
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
|
||||||
|
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟消息数据
|
// 模拟消息数据
|
||||||
@@ -224,9 +234,37 @@ const mockMessages: Message[] = [
|
|||||||
|
|
||||||
export default function BrandMessagesPage() {
|
export default function BrandMessagesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [messages, setMessages] = useState(mockMessages)
|
const [messages, setMessages] = useState<Message[]>(mockMessages)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
|
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.getMessages({ page: 1, page_size: 50 })
|
||||||
|
const mapped: Message[] = res.items.map(item => ({
|
||||||
|
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,
|
||||||
|
taskId: item.related_task_id || undefined,
|
||||||
|
projectId: item.related_project_id || undefined,
|
||||||
|
}))
|
||||||
|
setMessages(mapped)
|
||||||
|
} catch {
|
||||||
|
// 加载失败保持 mock 数据
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
const unreadCount = messages.filter(m => !m.read).length
|
const unreadCount = messages.filter(m => !m.read).length
|
||||||
const pendingReviewCount = messages.filter(m =>
|
const pendingReviewCount = messages.filter(m =>
|
||||||
!m.read && (m.type === 'agency_review_pass' || m.type === 'script_pending' || m.type === 'video_pending')
|
!m.read && (m.type === 'agency_review_pass' || m.type === 'script_pending' || m.type === 'video_pending')
|
||||||
@@ -247,12 +285,18 @@ export default function BrandMessagesPage() {
|
|||||||
|
|
||||||
const filteredMessages = getFilteredMessages()
|
const filteredMessages = getFilteredMessages()
|
||||||
|
|
||||||
const markAsRead = (id: string) => {
|
const markAsRead = async (id: string) => {
|
||||||
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
|
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
try { await api.markMessageAsRead(id) } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAllAsRead = () => {
|
const markAllAsRead = async () => {
|
||||||
setMessages(prev => prev.map(m => ({ ...m, read: true })))
|
setMessages(prev => prev.map(m => ({ ...m, read: true })))
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
try { await api.markAllMessagesAsRead() } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMessageClick = (message: Message) => {
|
const handleMessageClick = (message: Message) => {
|
||||||
@@ -376,7 +420,7 @@ export default function BrandMessagesPage() {
|
|||||||
{/* 消息列表 */}
|
{/* 消息列表 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredMessages.map((message) => {
|
{filteredMessages.map((message) => {
|
||||||
const config = messageConfig[message.type]
|
const config = messageConfig[message.type] || messageConfig.system_notice
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
const platform = message.platform ? getPlatformInfo(message.platform) : null
|
const platform = message.platform ? getPlatformInfo(message.platform) : null
|
||||||
|
|
||||||
|
|||||||
@@ -22,28 +22,29 @@ import { api } from '@/lib/api'
|
|||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useSSE } from '@/contexts/SSEContext'
|
import { useSSE } from '@/contexts/SSEContext'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
import type { ProjectResponse } from '@/types/project'
|
import type { ProjectResponse } from '@/types/project'
|
||||||
|
|
||||||
// ==================== Mock 数据 ====================
|
// ==================== Mock 数据 ====================
|
||||||
const mockProjects: ProjectResponse[] = [
|
const mockProjects: ProjectResponse[] = [
|
||||||
{
|
{
|
||||||
id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌',
|
id: 'proj-001', name: 'XX品牌618推广', brand_id: 'br-001', brand_name: 'XX品牌',
|
||||||
status: 'active', deadline: '2026-06-18', agencies: [],
|
platform: 'douyin', status: 'active', deadline: '2026-06-18', agencies: [],
|
||||||
task_count: 20, created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-05T00:00:00Z',
|
task_count: 20, created_at: '2026-02-01T00:00:00Z', updated_at: '2026-02-05T00:00:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'proj-002', name: '新品口红系列', brand_id: 'br-001', brand_name: 'XX品牌',
|
id: 'proj-002', name: '新品口红系列', brand_id: 'br-001', brand_name: 'XX品牌',
|
||||||
status: 'active', deadline: '2026-03-15', agencies: [],
|
platform: 'xiaohongshu', status: 'active', deadline: '2026-03-15', agencies: [],
|
||||||
task_count: 12, created_at: '2026-01-15T00:00:00Z', updated_at: '2026-02-01T00:00:00Z',
|
task_count: 12, created_at: '2026-01-15T00:00:00Z', updated_at: '2026-02-01T00:00:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'proj-003', name: '护肤品秋季活动', brand_id: 'br-001', brand_name: 'XX品牌',
|
id: 'proj-003', name: '护肤品秋季活动', brand_id: 'br-001', brand_name: 'XX品牌',
|
||||||
status: 'completed', deadline: '2025-11-30', agencies: [],
|
platform: 'bilibili', status: 'completed', deadline: '2025-11-30', agencies: [],
|
||||||
task_count: 15, created_at: '2025-08-01T00:00:00Z', updated_at: '2025-11-30T00:00:00Z',
|
task_count: 15, created_at: '2025-08-01T00:00:00Z', updated_at: '2025-11-30T00:00:00Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'proj-004', name: '双11预热活动', brand_id: 'br-001', brand_name: 'XX品牌',
|
id: 'proj-004', name: '双11预热活动', brand_id: 'br-001', brand_name: 'XX品牌',
|
||||||
status: 'active', deadline: '2026-11-11', agencies: [],
|
platform: 'kuaishou', status: 'active', deadline: '2026-11-11', agencies: [],
|
||||||
task_count: 18, created_at: '2026-01-10T00:00:00Z', updated_at: '2026-02-04T00:00:00Z',
|
task_count: 18, created_at: '2026-01-10T00:00:00Z', updated_at: '2026-02-04T00:00:00Z',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -58,11 +59,25 @@ function StatusTag({ status }: { status: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCard({ project, onEditDeadline }: { project: ProjectResponse; onEditDeadline: (project: ProjectResponse) => void }) {
|
function ProjectCard({ project, onEditDeadline }: { project: ProjectResponse; onEditDeadline: (project: ProjectResponse) => void }) {
|
||||||
|
const platformInfo = project.platform ? getPlatformInfo(project.platform) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/brand/projects/${project.id}`}>
|
<Link href={`/brand/projects/${project.id}`}>
|
||||||
<Card className="hover:border-accent-indigo/50 transition-colors cursor-pointer h-full overflow-hidden">
|
<Card className="hover:border-accent-indigo/50 transition-colors cursor-pointer h-full overflow-hidden">
|
||||||
<div className="px-6 py-2 bg-accent-indigo/10 border-b border-accent-indigo/20 flex items-center justify-between">
|
<div className={`px-6 py-2 border-b flex items-center justify-between ${
|
||||||
<span className="text-sm font-medium text-accent-indigo">{project.brand_name || '品牌项目'}</span>
|
platformInfo
|
||||||
|
? `${platformInfo.bgColor} ${platformInfo.borderColor}`
|
||||||
|
: 'bg-accent-indigo/10 border-accent-indigo/20'
|
||||||
|
}`}>
|
||||||
|
<span className={`text-sm font-medium flex items-center gap-1.5 ${
|
||||||
|
platformInfo ? platformInfo.textColor : 'text-accent-indigo'
|
||||||
|
}`}>
|
||||||
|
{platformInfo ? (
|
||||||
|
<><span>{platformInfo.icon}</span>{platformInfo.name}</>
|
||||||
|
) : (
|
||||||
|
project.brand_name || '品牌项目'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<StatusTag status={project.status} />
|
<StatusTag status={project.status} />
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6 space-y-4">
|
<CardContent className="p-6 space-y-4">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useRouter, useParams } from 'next/navigation'
|
import { useRouter, useParams } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent } from '@/components/ui/Card'
|
import { Card, CardContent } from '@/components/ui/Card'
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
AlertCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Bot,
|
Bot,
|
||||||
Users,
|
Users,
|
||||||
@@ -20,13 +21,28 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Loader2
|
Loader2,
|
||||||
|
Search,
|
||||||
|
RotateCcw
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
|
||||||
import { useOSSUpload } from '@/hooks/useOSSUpload'
|
import type { RuleConflict } from '@/types/rules'
|
||||||
import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
|
import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
|
||||||
|
|
||||||
|
// 单个文件的上传状态
|
||||||
|
interface UploadFileItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
size: string
|
||||||
|
status: 'uploading' | 'success' | 'error'
|
||||||
|
progress: number
|
||||||
|
url?: string
|
||||||
|
error?: string
|
||||||
|
file?: File
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Mock 数据 ====================
|
// ==================== Mock 数据 ====================
|
||||||
const mockBrief: BriefResponse = {
|
const mockBrief: BriefResponse = {
|
||||||
id: 'bf-001',
|
id: 'bf-001',
|
||||||
@@ -81,6 +97,13 @@ const mockRules = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + 'B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB'
|
||||||
|
}
|
||||||
|
|
||||||
// 严格程度选项
|
// 严格程度选项
|
||||||
const strictnessOptions = [
|
const strictnessOptions = [
|
||||||
{ value: 'low', label: '宽松', description: '仅检测明显违规内容' },
|
{ value: 'low', label: '宽松', description: '仅检测明显违规内容' },
|
||||||
@@ -109,8 +132,11 @@ export default function ProjectConfigPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { user } = useAuth()
|
||||||
const projectId = params.id as string
|
const projectId = params.id as string
|
||||||
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
|
|
||||||
|
// 附件上传跟踪
|
||||||
|
const [uploadingFiles, setUploadingFiles] = useState<UploadFileItem[]>([])
|
||||||
|
|
||||||
// Brief state
|
// Brief state
|
||||||
const [briefExists, setBriefExists] = useState(false)
|
const [briefExists, setBriefExists] = useState(false)
|
||||||
@@ -133,6 +159,82 @@ export default function ProjectConfigPage() {
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [activeSection, setActiveSection] = useState<string | null>('brief')
|
const [activeSection, setActiveSection] = useState<string | null>('brief')
|
||||||
|
|
||||||
|
// 规则冲突检测
|
||||||
|
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
|
||||||
|
const [showConflictModal, setShowConflictModal] = useState(false)
|
||||||
|
const [conflicts, setConflicts] = useState<RuleConflict[]>([])
|
||||||
|
const [showPlatformSelect, setShowPlatformSelect] = useState(false)
|
||||||
|
|
||||||
|
const platformDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const platformOptions = [
|
||||||
|
{ value: 'douyin', label: '抖音' },
|
||||||
|
{ value: 'xiaohongshu', label: '小红书' },
|
||||||
|
{ value: 'bilibili', label: 'B站' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 点击外部关闭平台选择下拉
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (platformDropdownRef.current && !platformDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setShowPlatformSelect(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showPlatformSelect) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showPlatformSelect])
|
||||||
|
|
||||||
|
const handleCheckConflicts = async (platform: string) => {
|
||||||
|
setShowPlatformSelect(false)
|
||||||
|
setIsCheckingConflicts(true)
|
||||||
|
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
setConflicts([
|
||||||
|
{
|
||||||
|
brief_rule: '卖点包含:100%纯天然成分',
|
||||||
|
platform_rule: `${platform} 禁止使用:100%`,
|
||||||
|
suggestion: "卖点 '100%纯天然成分' 包含违禁词 '100%',建议修改表述",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
brief_rule: 'Brief 最长时长:5秒',
|
||||||
|
platform_rule: `${platform} 最短要求:7秒`,
|
||||||
|
suggestion: 'Brief 最长 5s 低于平台最短要求 7s,视频可能不达标',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setShowConflictModal(true)
|
||||||
|
setIsCheckingConflicts(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const brandId = user?.brand_id || ''
|
||||||
|
const briefRules: Record<string, unknown> = {
|
||||||
|
selling_points: sellingPoints.map(sp => sp.content),
|
||||||
|
min_duration: minDuration,
|
||||||
|
max_duration: maxDuration,
|
||||||
|
}
|
||||||
|
const result = await api.validateRules({
|
||||||
|
brand_id: brandId,
|
||||||
|
platform,
|
||||||
|
brief_rules: briefRules,
|
||||||
|
})
|
||||||
|
setConflicts(result.conflicts)
|
||||||
|
if (result.conflicts.length > 0) {
|
||||||
|
setShowConflictModal(true)
|
||||||
|
} else {
|
||||||
|
toast.success('未发现规则冲突')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('规则冲突检测失败:', err)
|
||||||
|
toast.error('规则冲突检测失败')
|
||||||
|
} finally {
|
||||||
|
setIsCheckingConflicts(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Input fields
|
// Input fields
|
||||||
const [newSellingPoint, setNewSellingPoint] = useState('')
|
const [newSellingPoint, setNewSellingPoint] = useState('')
|
||||||
const [newBlacklistWord, setNewBlacklistWord] = useState('')
|
const [newBlacklistWord, setNewBlacklistWord] = useState('')
|
||||||
@@ -254,32 +356,71 @@ export default function ProjectConfigPage() {
|
|||||||
setCompetitors(competitors.filter(c => c !== name))
|
setCompetitors(competitors.filter(c => c !== name))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachment upload
|
// 上传单个附件(独立跟踪进度)
|
||||||
const handleAttachmentUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadSingleAttachment = async (file: File, fileId: string) => {
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
setAttachments([...attachments, {
|
for (let p = 20; p <= 80; p += 20) {
|
||||||
id: `att-${Date.now()}`,
|
await new Promise(r => setTimeout(r, 300))
|
||||||
name: file.name,
|
setUploadingFiles(prev => prev.map(f => f.id === fileId ? { ...f, progress: p } : f))
|
||||||
url: `mock://${file.name}`,
|
}
|
||||||
}])
|
await new Promise(r => setTimeout(r, 300))
|
||||||
|
const att: BriefAttachment = { id: fileId, name: file.name, url: `mock://${file.name}`, size: formatFileSize(file.size) }
|
||||||
|
setAttachments(prev => [...prev, att])
|
||||||
|
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await upload(file)
|
const result = await api.proxyUpload(file, 'general', (pct) => {
|
||||||
setAttachments([...attachments, {
|
setUploadingFiles(prev => prev.map(f => f.id === fileId
|
||||||
id: `att-${Date.now()}`,
|
? { ...f, progress: Math.min(95, Math.round(pct * 0.95)) }
|
||||||
name: file.name,
|
: f
|
||||||
url: result.url,
|
))
|
||||||
}])
|
})
|
||||||
} catch {
|
const att: BriefAttachment = { id: fileId, name: file.name, url: result.url, size: formatFileSize(file.size) }
|
||||||
toast.error('文件上传失败')
|
setAttachments(prev => [...prev, att])
|
||||||
|
setUploadingFiles(prev => prev.filter(f => f.id !== fileId))
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : '上传失败'
|
||||||
|
setUploadingFiles(prev => prev.map(f => f.id === fileId
|
||||||
|
? { ...f, status: 'error', error: msg }
|
||||||
|
: f
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAttachmentUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
const fileList = Array.from(files)
|
||||||
|
e.target.value = ''
|
||||||
|
const newItems: UploadFileItem[] = fileList.map(file => ({
|
||||||
|
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: file.name,
|
||||||
|
size: formatFileSize(file.size),
|
||||||
|
status: 'uploading' as const,
|
||||||
|
progress: 0,
|
||||||
|
file,
|
||||||
|
}))
|
||||||
|
setUploadingFiles(prev => [...prev, ...newItems])
|
||||||
|
newItems.forEach(item => uploadSingleAttachment(item.file!, item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAttachmentUpload = (fileId: string) => {
|
||||||
|
const item = uploadingFiles.find(f => f.id === fileId)
|
||||||
|
if (!item?.file) return
|
||||||
|
setUploadingFiles(prev => prev.map(f => f.id === fileId
|
||||||
|
? { ...f, status: 'uploading', progress: 0, error: undefined }
|
||||||
|
: f
|
||||||
|
))
|
||||||
|
uploadSingleAttachment(item.file, fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeUploadingFile = (id: string) => {
|
||||||
|
setUploadingFiles(prev => prev.filter(f => f.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
const removeAttachment = (id: string) => {
|
const removeAttachment = (id: string) => {
|
||||||
setAttachments(attachments.filter(a => a.id !== id))
|
setAttachments(attachments.filter(a => a.id !== id))
|
||||||
}
|
}
|
||||||
@@ -336,6 +477,40 @@ export default function ProjectConfigPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative" ref={platformDropdownRef}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowPlatformSelect(!showPlatformSelect)}
|
||||||
|
disabled={isCheckingConflicts}
|
||||||
|
>
|
||||||
|
{isCheckingConflicts ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
检测中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search size={16} />
|
||||||
|
检查规则冲突
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{showPlatformSelect && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-40 bg-bg-card border border-border-subtle rounded-xl shadow-lg z-50 overflow-hidden">
|
||||||
|
{platformOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCheckConflicts(opt.value)}
|
||||||
|
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated transition-colors"
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button variant="primary" onClick={handleSaveBrief} disabled={isSaving}>
|
<Button variant="primary" onClick={handleSaveBrief} disabled={isSaving}>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
@@ -350,6 +525,7 @@ export default function ProjectConfigPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Brief配置 */}
|
{/* Brief配置 */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -514,40 +690,99 @@ export default function ProjectConfigPage() {
|
|||||||
{/* 参考资料 */}
|
{/* 参考资料 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-text-secondary mb-2 block">参考资料</label>
|
<label className="text-sm text-text-secondary mb-2 block">参考资料</label>
|
||||||
<div className="space-y-2">
|
|
||||||
|
<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) => (
|
{attachments.map((att) => (
|
||||||
<div key={att.id} className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
|
<div key={att.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
<FileText size={16} className="text-accent-indigo" />
|
<CheckCircle size={14} className="text-accent-green flex-shrink-0" />
|
||||||
<span className="flex-1 text-text-primary">{att.name}</span>
|
<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>}
|
{att.size && <span className="text-xs text-text-tertiary">{att.size}</span>}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeAttachment(att.id)}
|
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} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<label className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-border-subtle bg-bg-elevated text-text-primary hover:bg-bg-page transition-colors cursor-pointer w-full text-sm">
|
|
||||||
{isUploading ? (
|
{/* 上传中/失败的文件 */}
|
||||||
<>
|
{uploadingFiles.map((file) => (
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<div key={file.id} className="px-4 py-2.5">
|
||||||
上传中 {uploadProgress}%
|
<div className="flex items-center gap-3">
|
||||||
</>
|
{file.status === 'uploading' && (
|
||||||
) : (
|
<Loader2 size={14} className="animate-spin text-accent-indigo flex-shrink-0" />
|
||||||
<>
|
)}
|
||||||
<Upload size={16} />
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -757,6 +992,54 @@ export default function ProjectConfigPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 规则冲突检测结果弹窗 */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showConflictModal}
|
||||||
|
onClose={() => setShowConflictModal(false)}
|
||||||
|
title="规则冲突检测结果"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{conflicts.length === 0 ? (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<CheckCircle size={48} className="mx-auto text-accent-green mb-3" />
|
||||||
|
<p className="text-text-primary font-medium">未发现冲突</p>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">Brief 内容与平台规则兼容</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-accent-amber/10 rounded-lg border border-accent-amber/30">
|
||||||
|
<AlertTriangle size={16} className="text-accent-amber flex-shrink-0" />
|
||||||
|
<p className="text-sm text-accent-amber">
|
||||||
|
发现 {conflicts.length} 处规则冲突,建议在发布前修改
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{conflicts.map((conflict, index) => (
|
||||||
|
<div key={index} className="p-4 bg-bg-elevated rounded-xl border border-border-subtle space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-xs font-medium text-accent-amber bg-accent-amber/15 px-2 py-0.5 rounded">Brief</span>
|
||||||
|
<span className="text-sm text-text-primary">{conflict.brief_rule}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-xs font-medium text-accent-coral bg-accent-coral/15 px-2 py-0.5 rounded">平台</span>
|
||||||
|
<span className="text-sm text-text-primary">{conflict.platform_rule}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 pt-1 border-t border-border-subtle">
|
||||||
|
<span className="text-xs font-medium text-accent-indigo bg-accent-indigo/15 px-2 py-0.5 rounded">建议</span>
|
||||||
|
<span className="text-sm text-text-secondary">{conflict.suggestion}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button variant="secondary" onClick={() => setShowConflictModal(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Video,
|
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
Loader2
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getPlatformInfo } from '@/lib/platforms'
|
import { getPlatformInfo } from '@/lib/platforms'
|
||||||
|
import { mapTaskToUI } from '@/lib/taskStageMapper'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useSSE } from '@/contexts/SSEContext'
|
import { useSSE } from '@/contexts/SSEContext'
|
||||||
@@ -77,6 +78,24 @@ const mockTasks: TaskResponse[] = [
|
|||||||
appeal_count: 0, is_appeal: false,
|
appeal_count: 0, is_appeal: false,
|
||||||
created_at: '2026-02-06T10:00:00Z', updated_at: '2026-02-06T10:00:00Z',
|
created_at: '2026-02-06T10:00:00Z', updated_at: '2026-02-06T10:00:00Z',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'task-004', name: '美白精华测评', sequence: 4,
|
||||||
|
stage: 'script_agency_review',
|
||||||
|
project: { id: 'proj-001', name: 'XX品牌618推广' },
|
||||||
|
agency: { id: 'AG456789', name: '创意无限' },
|
||||||
|
creator: { id: 'cr-004', name: '时尚小王' },
|
||||||
|
appeal_count: 0, is_appeal: false,
|
||||||
|
created_at: '2026-02-07T09:00:00Z', updated_at: '2026-02-07T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-005', name: '防晒霜种草', sequence: 5,
|
||||||
|
stage: 'script_upload',
|
||||||
|
project: { id: 'proj-001', name: 'XX品牌618推广' },
|
||||||
|
agency: { id: 'AG789012', name: '星耀传媒' },
|
||||||
|
creator: { id: 'cr-001', name: '小美护肤' },
|
||||||
|
appeal_count: 0, is_appeal: false,
|
||||||
|
created_at: '2026-02-07T11:00:00Z', updated_at: '2026-02-07T11:00:00Z',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const mockManagedAgencies: AgencyDetail[] = [
|
const mockManagedAgencies: AgencyDetail[] = [
|
||||||
@@ -136,6 +155,137 @@ function DetailSkeleton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 任务进度条 ====================
|
||||||
|
|
||||||
|
const SCRIPT_STEPS = [
|
||||||
|
{ key: 'script_upload', label: '上传' },
|
||||||
|
{ key: 'script_ai_review', label: 'AI' },
|
||||||
|
{ key: 'script_agency_review', label: '代理商' },
|
||||||
|
{ key: 'script_brand_review', label: '品牌' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const VIDEO_STEPS = [
|
||||||
|
{ key: 'video_upload', label: '上传' },
|
||||||
|
{ key: 'video_ai_review', label: 'AI' },
|
||||||
|
{ key: 'video_agency_review', label: '代理商' },
|
||||||
|
{ key: 'video_brand_review', label: '品牌' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function StepDot({ status }: { status: 'done' | 'current' | 'error' | 'pending' }) {
|
||||||
|
const base = 'w-3 h-3 rounded-full border-2 flex-shrink-0'
|
||||||
|
if (status === 'done') return <div className={`${base} bg-accent-green border-accent-green`} />
|
||||||
|
if (status === 'current') return <div className={`${base} bg-accent-indigo border-accent-indigo animate-pulse`} />
|
||||||
|
if (status === 'error') return <div className={`${base} bg-accent-coral border-accent-coral`} />
|
||||||
|
return <div className={`${base} bg-transparent border-border-strong`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepLine({ status }: { status: 'done' | 'pending' | 'error' }) {
|
||||||
|
if (status === 'done') return <div className="w-4 h-0.5 bg-accent-green mx-0.5" />
|
||||||
|
if (status === 'error') return <div className="w-4 h-0.5 bg-accent-coral mx-0.5" />
|
||||||
|
return <div className="w-4 h-0.5 bg-border-strong mx-0.5" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskProgressBar({ task }: { task: TaskResponse }) {
|
||||||
|
const ui = mapTaskToUI(task)
|
||||||
|
|
||||||
|
const scriptStatuses: Array<'done' | 'current' | 'error' | 'pending'> = [
|
||||||
|
ui.scriptStage.submit, ui.scriptStage.ai, ui.scriptStage.agency, ui.scriptStage.brand,
|
||||||
|
]
|
||||||
|
const videoStatuses: Array<'done' | 'current' | 'error' | 'pending'> = [
|
||||||
|
ui.videoStage.submit, ui.videoStage.ai, ui.videoStage.agency, ui.videoStage.brand,
|
||||||
|
]
|
||||||
|
|
||||||
|
const isCompleted = task.stage === 'completed'
|
||||||
|
const isRejected = task.stage === 'rejected'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* 脚本阶段 */}
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
<span className="text-[10px] text-text-tertiary mr-1.5 w-6">脚本</span>
|
||||||
|
{SCRIPT_STEPS.map((step, i) => (
|
||||||
|
<div key={step.key} className="flex items-center">
|
||||||
|
<div className="relative group">
|
||||||
|
<StepDot status={scriptStatuses[i]} />
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 bg-bg-card border border-border-subtle rounded text-[10px] text-text-secondary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
|
||||||
|
{step.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{i < SCRIPT_STEPS.length - 1 && (
|
||||||
|
<StepLine status={scriptStatuses[i] === 'done' ? 'done' : scriptStatuses[i] === 'error' ? 'error' : 'pending'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div className="w-3 h-0.5 bg-border-subtle mx-0.5" />
|
||||||
|
|
||||||
|
{/* 视频阶段 */}
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
<span className="text-[10px] text-text-tertiary mr-1.5 w-6">视频</span>
|
||||||
|
{VIDEO_STEPS.map((step, i) => (
|
||||||
|
<div key={step.key} className="flex items-center">
|
||||||
|
<div className="relative group">
|
||||||
|
<StepDot status={videoStatuses[i]} />
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 bg-bg-card border border-border-subtle rounded text-[10px] text-text-secondary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
|
||||||
|
{step.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{i < VIDEO_STEPS.length - 1 && (
|
||||||
|
<StepLine status={videoStatuses[i] === 'done' ? 'done' : videoStatuses[i] === 'error' ? 'error' : 'pending'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 完成标记 */}
|
||||||
|
<div className="ml-1.5">
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircle size={14} className="text-accent-green" />
|
||||||
|
) : isRejected ? (
|
||||||
|
<XCircle size={14} className="text-accent-coral" />
|
||||||
|
) : (
|
||||||
|
<div className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskGroup {
|
||||||
|
agencyId: string
|
||||||
|
agencyName: string
|
||||||
|
creators: {
|
||||||
|
creatorId: string
|
||||||
|
creatorName: string
|
||||||
|
tasks: TaskResponse[]
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupTasksByAgencyCreator(tasks: TaskResponse[]): TaskGroup[] {
|
||||||
|
const agencyMap = new Map<string, TaskGroup>()
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!agencyMap.has(task.agency.id)) {
|
||||||
|
agencyMap.set(task.agency.id, {
|
||||||
|
agencyId: task.agency.id,
|
||||||
|
agencyName: task.agency.name,
|
||||||
|
creators: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const group = agencyMap.get(task.agency.id)!
|
||||||
|
let creator = group.creators.find(c => c.creatorId === task.creator.id)
|
||||||
|
if (!creator) {
|
||||||
|
creator = { creatorId: task.creator.id, creatorName: task.creator.name, tasks: [] }
|
||||||
|
group.creators.push(creator)
|
||||||
|
}
|
||||||
|
creator.tasks.push(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(agencyMap.values())
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectDetailPage() {
|
export default function ProjectDetailPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -144,7 +294,7 @@ export default function ProjectDetailPage() {
|
|||||||
const { subscribe } = useSSE()
|
const { subscribe } = useSSE()
|
||||||
|
|
||||||
const [project, setProject] = useState<ProjectResponse | null>(null)
|
const [project, setProject] = useState<ProjectResponse | null>(null)
|
||||||
const [recentTasks, setRecentTasks] = useState<TaskResponse[]>([])
|
const [allTasks, setAllTasks] = useState<TaskResponse[]>([])
|
||||||
const [managedAgencies, setManagedAgencies] = useState<AgencyDetail[]>([])
|
const [managedAgencies, setManagedAgencies] = useState<AgencyDetail[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -162,7 +312,7 @@ export default function ProjectDetailPage() {
|
|||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
setProject(mockProject)
|
setProject(mockProject)
|
||||||
setRecentTasks(mockTasks)
|
setAllTasks(mockTasks)
|
||||||
setManagedAgencies(mockManagedAgencies)
|
setManagedAgencies(mockManagedAgencies)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
@@ -171,11 +321,11 @@ export default function ProjectDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const [projectData, tasksData, agenciesData] = await Promise.all([
|
const [projectData, tasksData, agenciesData] = await Promise.all([
|
||||||
api.getProject(projectId),
|
api.getProject(projectId),
|
||||||
api.listTasks(1, 10),
|
api.listTasks(1, 100, undefined, projectId),
|
||||||
api.listBrandAgencies(),
|
api.listBrandAgencies(),
|
||||||
])
|
])
|
||||||
setProject(projectData)
|
setProject(projectData)
|
||||||
setRecentTasks(tasksData.items.filter(t => t.project.id === projectId).slice(0, 5))
|
setAllTasks(tasksData.items)
|
||||||
setManagedAgencies(agenciesData.items)
|
setManagedAgencies(agenciesData.items)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project:', err)
|
console.error('Failed to load project:', err)
|
||||||
@@ -337,54 +487,74 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* 最近任务 */}
|
{/* 任务进度 */}
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<span>最近提交</span>
|
<span>任务进度</span>
|
||||||
<Link href="/brand/review">
|
<Link href="/brand/review">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
查看全部 <ChevronRight size={16} />
|
审核列表 <ChevronRight size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentTasks.length > 0 ? (
|
{allTasks.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="space-y-4">
|
||||||
<table className="w-full">
|
{/* 图例 */}
|
||||||
<thead>
|
<div className="flex items-center gap-4 text-[10px] text-text-tertiary pb-2 border-b border-border-subtle">
|
||||||
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary">
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent-green inline-block" /> 已完成</span>
|
||||||
<th className="pb-3 font-medium">任务</th>
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent-indigo inline-block" /> 进行中</span>
|
||||||
<th className="pb-3 font-medium">达人</th>
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full border border-border-strong inline-block" /> 待处理</span>
|
||||||
<th className="pb-3 font-medium">代理商</th>
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent-coral inline-block" /> 已驳回</span>
|
||||||
<th className="pb-3 font-medium">状态</th>
|
</div>
|
||||||
<th className="pb-3 font-medium">操作</th>
|
|
||||||
</tr>
|
{groupTasksByAgencyCreator(allTasks).map((group) => (
|
||||||
</thead>
|
<div key={group.agencyId} className="space-y-2">
|
||||||
<tbody>
|
{/* 代理商标题 */}
|
||||||
{recentTasks.map((task) => (
|
<div className="flex items-center gap-2">
|
||||||
<tr key={task.id} className="border-b border-border-subtle last:border-0 hover:bg-bg-elevated">
|
|
||||||
<td className="py-4 font-medium text-text-primary">{task.name}</td>
|
|
||||||
<td className="py-4 text-text-secondary">{task.creator.name}</td>
|
|
||||||
<td className="py-4">
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-bg-elevated text-sm">
|
|
||||||
<Building2 size={14} className="text-accent-indigo" />
|
<Building2 size={14} className="text-accent-indigo" />
|
||||||
<span className="text-text-secondary">{task.agency.name}</span>
|
<span className="text-sm font-medium text-text-primary">{group.agencyName}</span>
|
||||||
|
<span className="text-xs text-text-tertiary">
|
||||||
|
({group.creators.reduce((sum, c) => sum + c.tasks.length, 0)} 个任务)
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</div>
|
||||||
<td className="py-4"><TaskStatusTag stage={task.stage} /></td>
|
|
||||||
<td className="py-4">
|
{/* 达人列表 */}
|
||||||
<Link href={`/agency/review/${task.id}`}>
|
<div className="ml-4 space-y-1">
|
||||||
<Button size="sm" variant={task.stage.includes('review') ? 'primary' : 'secondary'}>
|
{group.creators.map((creator) => (
|
||||||
{task.stage.includes('review') ? '审核' : '查看'}
|
<div key={creator.creatorId} className="space-y-1">
|
||||||
</Button>
|
{/* 达人名称 */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-accent-green/15 flex items-center justify-center">
|
||||||
|
<Users size={10} className="text-accent-green" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-text-secondary">{creator.creatorName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务进度条 */}
|
||||||
|
<div className="ml-6 space-y-1.5">
|
||||||
|
{creator.tasks.map((task) => (
|
||||||
|
<div key={task.id} className="flex items-center gap-3 py-1.5 px-2 rounded-lg hover:bg-bg-elevated group/task">
|
||||||
|
<span className="text-xs text-text-primary min-w-[80px] truncate font-medium">{task.name}</span>
|
||||||
|
<TaskProgressBar task={task} />
|
||||||
|
<span className="text-[10px] text-text-tertiary ml-auto hidden group-hover/task:block">
|
||||||
|
<TaskStatusTag stage={task.stage} />
|
||||||
|
</span>
|
||||||
|
<Link href={task.stage.includes('video') ? `/brand/review/video/${task.id}` : `/brand/review/script/${task.id}`}>
|
||||||
|
<button type="button" className="text-xs text-accent-indigo hover:text-accent-indigo/80 opacity-0 group-hover/task:opacity-100 transition-opacity">
|
||||||
|
{task.stage.includes('brand_review') ? '审核' : '查看'}
|
||||||
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-text-tertiary text-sm">暂无任务</div>
|
<div className="text-center py-8 text-text-tertiary text-sm">暂无任务</div>
|
||||||
|
|||||||
@@ -12,17 +12,38 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
FileText,
|
FileText,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
X,
|
AlertCircle,
|
||||||
Users,
|
|
||||||
Search,
|
Search,
|
||||||
Building2,
|
Building2,
|
||||||
Check,
|
Loader2,
|
||||||
Loader2
|
Trash2,
|
||||||
|
RotateCcw
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useOSSUpload } from '@/hooks/useOSSUpload'
|
import { platformOptions } from '@/lib/platforms'
|
||||||
import type { AgencyDetail } from '@/types/organization'
|
import type { AgencyDetail } from '@/types/organization'
|
||||||
|
import type { BriefAttachment } from '@/types/brief'
|
||||||
|
|
||||||
|
// 单个文件的上传状态
|
||||||
|
interface UploadFileItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
size: string
|
||||||
|
rawSize: number
|
||||||
|
status: 'uploading' | 'success' | 'error'
|
||||||
|
progress: number
|
||||||
|
url?: string
|
||||||
|
error?: string
|
||||||
|
file?: File // 保留引用用于重试
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + 'B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB'
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Mock 数据 ====================
|
// ==================== Mock 数据 ====================
|
||||||
const mockAgencies: AgencyDetail[] = [
|
const mockAgencies: AgencyDetail[] = [
|
||||||
@@ -37,19 +58,25 @@ const mockAgencies: AgencyDetail[] = [
|
|||||||
export default function CreateProjectPage() {
|
export default function CreateProjectPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
|
|
||||||
|
|
||||||
const [projectName, setProjectName] = useState('')
|
const [projectName, setProjectName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
const [platform, setPlatform] = useState('douyin')
|
||||||
const [deadline, setDeadline] = useState('')
|
const [deadline, setDeadline] = useState('')
|
||||||
const [briefFile, setBriefFile] = useState<File | null>(null)
|
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([])
|
||||||
const [briefFileUrl, setBriefFileUrl] = useState<string | null>(null)
|
|
||||||
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
|
const [selectedAgencies, setSelectedAgencies] = useState<string[]>([])
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [agencySearch, setAgencySearch] = useState('')
|
const [agencySearch, setAgencySearch] = useState('')
|
||||||
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
|
const [agencies, setAgencies] = useState<AgencyDetail[]>([])
|
||||||
const [loadingAgencies, setLoadingAgencies] = useState(true)
|
const [loadingAgencies, setLoadingAgencies] = useState(true)
|
||||||
|
|
||||||
|
// 从成功上传的文件中提取 BriefAttachment
|
||||||
|
const briefFiles: BriefAttachment[] = uploadFiles
|
||||||
|
.filter(f => f.status === 'success' && f.url)
|
||||||
|
.map(f => ({ id: f.id, name: f.name, url: f.url!, size: f.size }))
|
||||||
|
|
||||||
|
const hasUploading = uploadFiles.some(f => f.status === 'uploading')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAgencies = async () => {
|
const loadAgencies = async () => {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
@@ -76,22 +103,85 @@ export default function CreateProjectPage() {
|
|||||||
agency.id.toLowerCase().includes(agencySearch.toLowerCase())
|
agency.id.toLowerCase().includes(agencySearch.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
// 上传单个文件(独立跟踪进度)
|
||||||
const file = e.target.files?.[0]
|
const uploadSingleFile = async (file: File, fileId: string) => {
|
||||||
if (!file) return
|
if (USE_MOCK) {
|
||||||
setBriefFile(file)
|
// Mock:模拟进度
|
||||||
|
for (let p = 20; p <= 80; p += 20) {
|
||||||
|
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 {
|
try {
|
||||||
const result = await upload(file)
|
const result = await api.proxyUpload(file, 'general', (pct) => {
|
||||||
setBriefFileUrl(result.url)
|
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) {
|
} catch (err) {
|
||||||
toast.error('文件上传失败')
|
const msg = err instanceof Error ? err.message : '上传失败'
|
||||||
setBriefFile(null)
|
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) => {
|
const toggleAgency = (agencyId: string) => {
|
||||||
@@ -116,15 +206,15 @@ export default function CreateProjectPage() {
|
|||||||
const project = await api.createProject({
|
const project = await api.createProject({
|
||||||
name: projectName.trim(),
|
name: projectName.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
|
platform,
|
||||||
deadline,
|
deadline,
|
||||||
agency_ids: selectedAgencies,
|
agency_ids: selectedAgencies,
|
||||||
})
|
})
|
||||||
|
|
||||||
// If brief file was uploaded, create brief
|
// If brief files were uploaded, create brief with attachments
|
||||||
if (briefFileUrl && briefFile) {
|
if (briefFiles.length > 0) {
|
||||||
await api.createBrief(project.id, {
|
await api.createBrief(project.id, {
|
||||||
file_url: briefFileUrl,
|
attachments: briefFiles,
|
||||||
file_name: briefFile.name,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +267,35 @@ export default function CreateProjectPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 发布平台 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||||
|
发布平台 <span className="text-accent-coral">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{platformOptions.map((p) => {
|
||||||
|
const isSelected = platform === p.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPlatform(p.id)}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl border-2 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? `${p.borderColor} ${p.bgColor} border-opacity-100`
|
||||||
|
: 'border-border-subtle hover:border-accent-indigo/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xl">{p.icon}</span>
|
||||||
|
<span className={`font-medium ${isSelected ? p.textColor : 'text-text-secondary'}`}>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 截止日期 */}
|
{/* 截止日期 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||||
@@ -195,35 +314,125 @@ export default function CreateProjectPage() {
|
|||||||
|
|
||||||
{/* Brief 上传 */}
|
{/* Brief 上传 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">上传 Brief</label>
|
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||||
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
|
上传 Brief 文档
|
||||||
{briefFile ? (
|
</label>
|
||||||
<div className="flex items-center justify-center gap-3">
|
|
||||||
<FileText size={24} className="text-accent-indigo" />
|
{/* 上传区域 */}
|
||||||
<span className="text-text-primary">{briefFile.name}</span>
|
<label className="border-2 border-dashed border-border-subtle rounded-lg p-6 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block mb-3">
|
||||||
{isUploading && (
|
<Upload size={28} className="mx-auto text-text-tertiary mb-2" />
|
||||||
<span className="text-xs text-text-tertiary">{uploadProgress}%</span>
|
<p className="text-text-secondary text-sm mb-1">
|
||||||
)}
|
{uploadFiles.length > 0 ? '继续添加文件' : '点击上传 Brief 文件(可多选)'}
|
||||||
<button
|
</p>
|
||||||
type="button"
|
<p className="text-xs text-text-tertiary">支持 PDF、Word、Excel、图片等格式</p>
|
||||||
onClick={() => { setBriefFile(null); setBriefFileUrl(null) }}
|
|
||||||
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">支持 PDF、Word、Excel 格式</p>
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx"
|
multiple
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +520,7 @@ export default function CreateProjectPage() {
|
|||||||
<Button variant="secondary" onClick={() => router.back()}>
|
<Button variant="secondary" onClick={() => router.back()}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || isUploading}>
|
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting || hasUploading}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Download, Calendar, Filter } from 'lucide-react'
|
import { Download, Calendar, Filter } from 'lucide-react'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
@@ -43,9 +45,33 @@ const platformOptions = [
|
|||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const [period, setPeriod] = useState('7d')
|
const [period, setPeriod] = useState('7d')
|
||||||
const [platform, setPlatform] = useState('all')
|
const [platform, setPlatform] = useState('all')
|
||||||
|
const [reportData, setReportData] = useState(mockReportData)
|
||||||
|
const [reviewRecords, setReviewRecords] = useState(mockReviewRecords)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
// Mock 模式:直接使用本地 mock 数据
|
||||||
|
setReportData(mockReportData)
|
||||||
|
setReviewRecords(mockReviewRecords)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: 后端报表 API 待实现 (GET /api/v1/reports),暂时使用 mock 数据
|
||||||
|
try {
|
||||||
|
setReportData(mockReportData)
|
||||||
|
setReviewRecords(mockReviewRecords)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
// 计算汇总数据
|
// 计算汇总数据
|
||||||
const summary = mockReportData.reduce(
|
const summary = reportData.reduce(
|
||||||
(acc, day) => ({
|
(acc, day) => ({
|
||||||
totalSubmitted: acc.totalSubmitted + day.submitted,
|
totalSubmitted: acc.totalSubmitted + day.submitted,
|
||||||
totalPassed: acc.totalPassed + day.passed,
|
totalPassed: acc.totalPassed + day.passed,
|
||||||
@@ -53,7 +79,7 @@ export default function ReportsPage() {
|
|||||||
}),
|
}),
|
||||||
{ totalSubmitted: 0, totalPassed: 0, totalFailed: 0 }
|
{ totalSubmitted: 0, totalPassed: 0, totalFailed: 0 }
|
||||||
)
|
)
|
||||||
const passRate = Math.round((summary.totalPassed / summary.totalSubmitted) * 100)
|
const passRate = summary.totalSubmitted > 0 ? Math.round((summary.totalPassed / summary.totalSubmitted) * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -127,7 +153,7 @@ export default function ReportsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{mockReportData.map((row) => (
|
{reportData.map((row) => (
|
||||||
<tr key={row.id} className="border-b last:border-0">
|
<tr key={row.id} className="border-b last:border-0">
|
||||||
<td className="py-3 font-medium text-gray-900">{row.date}</td>
|
<td className="py-3 font-medium text-gray-900">{row.date}</td>
|
||||||
<td className="py-3 text-gray-600">{row.submitted}</td>
|
<td className="py-3 text-gray-600">{row.submitted}</td>
|
||||||
@@ -168,7 +194,7 @@ export default function ReportsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{mockReviewRecords.map((record) => (
|
{reviewRecords.map((record) => (
|
||||||
<tr key={record.id} className="border-b last:border-0 hover:bg-gray-50">
|
<tr key={record.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||||
<td className="py-3 font-medium text-gray-900">{record.videoTitle}</td>
|
<td className="py-3 font-medium text-gray-900">{record.videoTitle}</td>
|
||||||
<td className="py-3 text-gray-600">{record.creator}</td>
|
<td className="py-3 text-gray-600">{record.creator}</td>
|
||||||
|
|||||||
@@ -173,12 +173,12 @@ function mapTaskToUI(task: TaskResponse, type: 'script' | 'video'): UITask {
|
|||||||
// 格式化提交时间
|
// 格式化提交时间
|
||||||
const submittedAt = formatDateTime(task.updated_at)
|
const submittedAt = formatDateTime(task.updated_at)
|
||||||
|
|
||||||
// 平台信息:后端目前不返回平台字段,默认 douyin
|
// 平台信息:从项目获取
|
||||||
const platform = 'douyin'
|
const platform = task.project.platform || ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.name,
|
title: `${task.project.name} · ${task.name}`,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize: isScript ? '--' : '--',
|
fileSize: isScript ? '--' : '--',
|
||||||
creatorName: task.creator.name,
|
creatorName: task.creator.name,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const mockScriptTask = {
|
|||||||
},
|
},
|
||||||
aiAnalysis: {
|
aiAnalysis: {
|
||||||
violations: [
|
violations: [
|
||||||
{ id: 'v1', type: '违禁词', content: '神器', suggestion: '建议替换为"好物"或"必备品"', severity: 'medium' },
|
{ id: 'v1', type: '违禁词', content: '神器', suggestion: '建议替换为"好物"或"必备品"', severity: 'medium', dimension: 'legal' },
|
||||||
],
|
],
|
||||||
complianceChecks: [
|
complianceChecks: [
|
||||||
{ item: '品牌名称正确', passed: true },
|
{ item: '品牌名称正确', passed: true },
|
||||||
@@ -71,6 +71,17 @@ const mockScriptTask = {
|
|||||||
{ item: '无绝对化用语', passed: false, note: '"超级好用"建议修改' },
|
{ item: '无绝对化用语', passed: false, note: '"超级好用"建议修改' },
|
||||||
{ item: '引导语规范', passed: true },
|
{ item: '引导语规范', passed: true },
|
||||||
],
|
],
|
||||||
|
dimensions: {
|
||||||
|
legal: { score: 85, passed: true, issue_count: 1 },
|
||||||
|
platform: { score: 100, passed: true, issue_count: 0 },
|
||||||
|
brand_safety: { score: 100, passed: true, issue_count: 0 },
|
||||||
|
brief_match: { score: 100, passed: true, issue_count: 0 },
|
||||||
|
},
|
||||||
|
sellingPointMatches: [
|
||||||
|
{ content: 'SPF50+ PA++++', priority: 'core' as const, matched: true, evidence: '脚本提及 SPF50+,PA++++' },
|
||||||
|
{ content: '轻薄质地', priority: 'core' as const, matched: true, evidence: '脚本描述质地轻薄不油腻' },
|
||||||
|
{ content: '延展性好', priority: 'recommended' as const, matched: true, evidence: '脚本演示延展性' },
|
||||||
|
],
|
||||||
sellingPoints: [
|
sellingPoints: [
|
||||||
{ point: 'SPF50+ PA++++', covered: true },
|
{ point: 'SPF50+ PA++++', covered: true },
|
||||||
{ point: '轻薄质地', covered: true },
|
{ point: '轻薄质地', covered: true },
|
||||||
@@ -88,6 +99,7 @@ function mapTaskToView(task: TaskResponse) {
|
|||||||
content: v.content,
|
content: v.content,
|
||||||
suggestion: v.suggestion,
|
suggestion: v.suggestion,
|
||||||
severity: v.severity,
|
severity: v.severity,
|
||||||
|
dimension: v.dimension,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const softWarnings = (task.script_ai_result?.soft_warnings || []).map((w, idx) => ({
|
const softWarnings = (task.script_ai_result?.soft_warnings || []).map((w, idx) => ({
|
||||||
@@ -138,6 +150,8 @@ function mapTaskToView(task: TaskResponse) {
|
|||||||
aiAnalysis: {
|
aiAnalysis: {
|
||||||
violations,
|
violations,
|
||||||
softWarnings,
|
softWarnings,
|
||||||
|
dimensions: task.script_ai_result?.dimensions,
|
||||||
|
sellingPointMatches: task.script_ai_result?.selling_point_matches || [],
|
||||||
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
|
sellingPoints: [] as Array<{ point: string; covered: boolean }>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -236,6 +250,7 @@ export default function BrandScriptReviewPage() {
|
|||||||
},
|
},
|
||||||
} : taskData
|
} : taskData
|
||||||
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
const handleApprove = async () => {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
setShowApproveModal(false)
|
setShowApproveModal(false)
|
||||||
@@ -492,6 +507,34 @@ export default function BrandScriptReviewPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 维度评分 */}
|
||||||
|
{task.aiAnalysis.dimensions && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Shield size={16} className="text-accent-indigo" />
|
||||||
|
维度评分
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
|
||||||
|
const dim = (task.aiAnalysis.dimensions as unknown as Record<string, { score: number; passed: boolean; issue_count: number }>)?.[key]
|
||||||
|
if (!dim) return null
|
||||||
|
const label = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[key]
|
||||||
|
return (
|
||||||
|
<div key={key} className={`flex items-center justify-between p-2 rounded-lg ${dim.passed ? 'bg-accent-green/5' : 'bg-accent-coral/5'}`}>
|
||||||
|
<span className="text-sm text-text-primary">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm font-bold ${dim.passed ? 'text-accent-green' : 'text-accent-coral'}`}>{dim.score}</span>
|
||||||
|
{dim.passed ? <CheckCircle size={14} className="text-accent-green" /> : <XCircle size={14} className="text-accent-coral" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 违规检测 */}
|
{/* 违规检测 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
@@ -505,6 +548,7 @@ export default function BrandScriptReviewPage() {
|
|||||||
<div key={v.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
<div key={v.id} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<WarningTag>{v.type}</WarningTag>
|
<WarningTag>{v.type}</WarningTag>
|
||||||
|
{v.dimension && <span className="text-xs text-text-tertiary">{{ legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }[v.dimension as string]}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-primary">{v.content}</p>
|
<p className="text-sm text-text-primary">{v.content}</p>
|
||||||
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||||
@@ -568,26 +612,41 @@ export default function BrandScriptReviewPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 卖点覆盖 */}
|
{/* 卖点匹配 */}
|
||||||
{task.aiAnalysis.sellingPoints.length > 0 && (
|
{(task.aiAnalysis.sellingPointMatches?.length > 0 || task.aiAnalysis.sellingPoints.length > 0) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<CheckCircle size={16} className="text-accent-green" />
|
<CheckCircle size={16} className="text-accent-green" />
|
||||||
卖点覆盖
|
卖点匹配
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{task.aiAnalysis.sellingPoints.map((sp, idx) => (
|
{task.aiAnalysis.sellingPointMatches && task.aiAnalysis.sellingPointMatches.length > 0 ? (
|
||||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
|
task.aiAnalysis.sellingPointMatches.map((sp: { content: string; priority: string; matched: boolean; evidence?: string }, idx: number) => (
|
||||||
{sp.covered ? (
|
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||||
<CheckCircle size={16} className="text-accent-green" />
|
{sp.matched ? <CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-text-primary">{sp.content}</span>
|
||||||
|
<span className={`px-1.5 py-0.5 text-xs rounded ${
|
||||||
|
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||||
|
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||||
|
'bg-bg-page text-text-tertiary'
|
||||||
|
}`}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
|
||||||
|
</div>
|
||||||
|
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<XCircle size={16} className="text-accent-coral" />
|
task.aiAnalysis.sellingPoints.map((sp, idx) => (
|
||||||
)}
|
<div key={idx} className="flex items-center gap-2 p-2 rounded-lg bg-bg-elevated">
|
||||||
|
{sp.covered ? <CheckCircle size={16} className="text-accent-green" /> : <XCircle size={16} className="text-accent-coral" />}
|
||||||
<span className="text-sm text-text-primary">{sp.point}</span>
|
<span className="text-sm text-text-primary">{sp.point}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export default function BrandVideoReviewPage() {
|
|||||||
const [showFilePreview, setShowFilePreview] = useState(false)
|
const [showFilePreview, setShowFilePreview] = useState(false)
|
||||||
const [videoError, setVideoError] = useState(false)
|
const [videoError, setVideoError] = useState(false)
|
||||||
|
|
||||||
|
|
||||||
// 加载任务数据
|
// 加载任务数据
|
||||||
const loadTask = useCallback(async () => {
|
const loadTask = useCallback(async () => {
|
||||||
if (!taskId) return
|
if (!taskId) return
|
||||||
|
|||||||
+541
-564
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,8 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
Phone
|
Phone
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
export default function BrandSettingsPage() {
|
export default function BrandSettingsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -105,14 +107,32 @@ export default function BrandSettingsPage() {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChangePassword = () => {
|
const handleChangePassword = async () => {
|
||||||
if (passwordForm.new !== passwordForm.confirm) {
|
if (passwordForm.new !== passwordForm.confirm) {
|
||||||
toast.error('两次输入的密码不一致')
|
toast.error('两次输入的密码不一致')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!passwordForm.current || !passwordForm.new) {
|
||||||
|
toast.error('请填写完整密码信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (USE_MOCK) {
|
||||||
toast.success('密码修改成功')
|
toast.success('密码修改成功')
|
||||||
setShowPasswordModal(false)
|
setShowPasswordModal(false)
|
||||||
setPasswordForm({ current: '', new: '', confirm: '' })
|
setPasswordForm({ current: '', new: '', confirm: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.changePassword({
|
||||||
|
old_password: passwordForm.current,
|
||||||
|
new_password: passwordForm.new,
|
||||||
|
})
|
||||||
|
toast.success('密码修改成功')
|
||||||
|
setShowPasswordModal(false)
|
||||||
|
setPasswordForm({ current: '', new: '', confirm: '' })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || '密码修改失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEnable2FA = () => {
|
const handleEnable2FA = () => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
UserPlus,
|
UserPlus,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -45,6 +47,9 @@ type MessageType =
|
|||||||
| 'task_deadline' // 任务截止提醒
|
| 'task_deadline' // 任务截止提醒
|
||||||
| 'brief_updated' // Brief更新通知
|
| 'brief_updated' // Brief更新通知
|
||||||
| 'system_notice' // 系统通知
|
| 'system_notice' // 系统通知
|
||||||
|
| 'reject' // 审核驳回
|
||||||
|
| 'force_pass' // 强制通过
|
||||||
|
| 'approve' // 审核批准
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
id: string
|
id: string
|
||||||
@@ -85,6 +90,9 @@ const messageConfig: Record<MessageType, {
|
|||||||
task_deadline: { icon: CalendarClock, iconColor: 'text-orange-400', bgColor: 'bg-orange-500/20' },
|
task_deadline: { icon: CalendarClock, iconColor: 'text-orange-400', bgColor: 'bg-orange-500/20' },
|
||||||
brief_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
brief_updated: { icon: FileText, iconColor: 'text-accent-indigo', bgColor: 'bg-accent-indigo/20' },
|
||||||
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
|
system_notice: { icon: Bell, iconColor: 'text-text-secondary', bgColor: 'bg-bg-elevated' },
|
||||||
|
reject: { icon: XCircle, iconColor: 'text-accent-coral', bgColor: 'bg-accent-coral/20' },
|
||||||
|
force_pass: { icon: CheckCircle, iconColor: 'text-accent-amber', bgColor: 'bg-accent-amber/20' },
|
||||||
|
approve: { icon: CheckCircle, iconColor: 'text-accent-green', bgColor: 'bg-accent-green/20' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12条消息数据
|
// 12条消息数据
|
||||||
@@ -279,7 +287,7 @@ function MessageCard({
|
|||||||
onAcceptInvite?: () => void
|
onAcceptInvite?: () => void
|
||||||
onIgnoreInvite?: () => void
|
onIgnoreInvite?: () => void
|
||||||
}) {
|
}) {
|
||||||
const config = messageConfig[message.type]
|
const config = messageConfig[message.type] || messageConfig.system_notice
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -466,7 +474,8 @@ function SuccessModal({
|
|||||||
|
|
||||||
export default function CreatorMessagesPage() {
|
export default function CreatorMessagesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [messages, setMessages] = useState(mockMessages)
|
const [messages, setMessages] = useState<Message[]>(mockMessages)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; type: 'accept' | 'ignore'; messageId: string }>({
|
const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; type: 'accept' | 'ignore'; messageId: string }>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
type: 'accept',
|
type: 'accept',
|
||||||
@@ -477,14 +486,47 @@ export default function CreatorMessagesPage() {
|
|||||||
message: '',
|
message: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const markAsRead = (id: string) => {
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
setMessages(mockMessages)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.getMessages({ page: 1, page_size: 50 })
|
||||||
|
const mapped: Message[] = res.items.map(item => ({
|
||||||
|
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,
|
||||||
|
taskId: item.related_task_id || undefined,
|
||||||
|
}))
|
||||||
|
setMessages(mapped)
|
||||||
|
} catch {
|
||||||
|
// 加载失败保持 mock 数据
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
|
const markAsRead = async (id: string) => {
|
||||||
setMessages(prev => prev.map(msg =>
|
setMessages(prev => prev.map(msg =>
|
||||||
msg.id === id ? { ...msg, read: true } : msg
|
msg.id === id ? { ...msg, read: true } : msg
|
||||||
))
|
))
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
try { await api.markMessageAsRead(id) } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAllAsRead = () => {
|
const markAllAsRead = async () => {
|
||||||
setMessages(prev => prev.map(msg => ({ ...msg, read: true })))
|
setMessages(prev => prev.map(msg => ({ ...msg, read: true })))
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
try { await api.markAllMessagesAsRead() } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据消息类型跳转到对应页面
|
// 根据消息类型跳转到对应页面
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function mapTaskResponseToUI(task: TaskResponse): Task {
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.name,
|
title: task.name,
|
||||||
description: `${task.project.name} · ${ui.statusLabel}`,
|
description: `${task.project.name} · ${ui.statusLabel}`,
|
||||||
platform: 'douyin', // 后端暂无平台字段,默认
|
platform: task.project?.platform || 'douyin',
|
||||||
scriptStage: ui.scriptStage,
|
scriptStage: ui.scriptStage,
|
||||||
videoStage: ui.videoStage,
|
videoStage: ui.videoStage,
|
||||||
buttonText: ui.buttonText,
|
buttonText: ui.buttonText,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { ArrowLeft, Camera, Check, Copy } from 'lucide-react'
|
import { ArrowLeft, Camera, Check, Copy } from 'lucide-react'
|
||||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||||
@@ -8,6 +8,8 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
// 模拟用户数据
|
// 模拟用户数据
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
@@ -32,11 +34,29 @@ export default function ProfileEditPage() {
|
|||||||
douyinAccount: mockUser.douyinAccount,
|
douyinAccount: mockUser.douyinAccount,
|
||||||
bio: mockUser.bio,
|
bio: mockUser.bio,
|
||||||
})
|
})
|
||||||
|
const [creatorId, setCreatorId] = useState(mockUser.creatorId)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (USE_MOCK) return
|
||||||
|
try {
|
||||||
|
const profile = await api.getProfile()
|
||||||
|
setFormData({
|
||||||
|
name: profile.name || '',
|
||||||
|
phone: profile.phone || '',
|
||||||
|
email: profile.email || '',
|
||||||
|
douyinAccount: profile.creator?.douyin_account || '',
|
||||||
|
bio: profile.creator?.bio || '',
|
||||||
|
})
|
||||||
|
if (profile.creator?.id) setCreatorId(profile.creator.id)
|
||||||
|
} catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
// 复制达人ID
|
// 复制达人ID
|
||||||
const handleCopyId = async () => {
|
const handleCopyId = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(mockUser.creatorId)
|
await navigator.clipboard.writeText(creatorId)
|
||||||
setIdCopied(true)
|
setIdCopied(true)
|
||||||
setTimeout(() => setIdCopied(false), 2000)
|
setTimeout(() => setIdCopied(false), 2000)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -52,8 +72,22 @@ export default function ProfileEditPage() {
|
|||||||
// 保存
|
// 保存
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
// 模拟保存
|
if (USE_MOCK) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await api.updateProfile({
|
||||||
|
name: formData.name,
|
||||||
|
phone: formData.phone,
|
||||||
|
bio: formData.bio,
|
||||||
|
douyin_account: formData.douyinAccount,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || '保存失败')
|
||||||
|
setIsSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
router.back()
|
router.back()
|
||||||
}
|
}
|
||||||
@@ -89,7 +123,7 @@ export default function ProfileEditPage() {
|
|||||||
background: 'linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)',
|
background: 'linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-[40px] font-bold text-white">{mockUser.initial}</span>
|
<span className="text-[40px] font-bold text-white">{formData.name?.[0] || '?'}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 相机按钮 */}
|
{/* 相机按钮 */}
|
||||||
<button
|
<button
|
||||||
@@ -118,7 +152,7 @@ export default function ProfileEditPage() {
|
|||||||
<label className="text-sm font-medium text-text-primary">达人ID</label>
|
<label className="text-sm font-medium text-text-primary">达人ID</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex-1 px-4 py-3 rounded-xl border border-border-default bg-bg-elevated/50 flex items-center justify-between">
|
<div className="flex-1 px-4 py-3 rounded-xl border border-border-default bg-bg-elevated/50 flex items-center justify-between">
|
||||||
<span className="font-mono font-medium text-accent-indigo">{mockUser.creatorId}</span>
|
<span className="font-mono font-medium text-accent-indigo">{creatorId}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyId}
|
onClick={handleCopyId}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
|
||||||
// 模拟登录设备数据
|
// 模拟登录设备数据
|
||||||
const mockDevices = [
|
const mockDevices = [
|
||||||
@@ -108,12 +111,31 @@ function ChangePasswordModal({
|
|||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
})
|
})
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (formData.newPassword !== formData.confirmPassword) {
|
||||||
|
toast.error('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
if (USE_MOCK) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
setStep(2)
|
setStep(2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.changePassword({
|
||||||
|
old_password: formData.currentPassword,
|
||||||
|
new_password: formData.newPassword,
|
||||||
|
})
|
||||||
|
setIsSaving(false)
|
||||||
|
setStep(2)
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsSaving(false)
|
||||||
|
toast.error(err.message || '密码修改失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Mail,
|
Mail,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type AgencyBriefFile = {
|
|||||||
size: string
|
size: string
|
||||||
uploadedAt: string
|
uploadedAt: string
|
||||||
description?: string
|
description?: string
|
||||||
|
url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面视图模型
|
// 页面视图模型
|
||||||
@@ -102,20 +103,24 @@ function buildMockViewModel(): BriefViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefViewModel {
|
function buildViewModelFromAPI(task: TaskResponse, brief: BriefResponse): BriefViewModel {
|
||||||
// Map attachments to file list
|
// 优先显示代理商上传的文档,没有则降级到品牌方附件
|
||||||
const files: AgencyBriefFile[] = (brief.attachments ?? []).map((att, idx) => ({
|
const agencyAtts = brief.agency_attachments ?? []
|
||||||
|
const brandAtts = brief.attachments ?? []
|
||||||
|
const sourceAtts = agencyAtts.length > 0 ? agencyAtts : brandAtts
|
||||||
|
const files: AgencyBriefFile[] = sourceAtts.map((att, idx) => ({
|
||||||
id: att.id || `att-${idx}`,
|
id: att.id || `att-${idx}`,
|
||||||
name: att.name,
|
name: att.name,
|
||||||
size: att.size || '',
|
size: att.size || '',
|
||||||
uploadedAt: brief.updated_at?.split('T')[0] || '',
|
uploadedAt: brief.updated_at?.split('T')[0] || '',
|
||||||
description: undefined,
|
description: undefined,
|
||||||
|
url: att.url,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Map selling points
|
// Map selling points
|
||||||
const sellingPoints = (brief.selling_points ?? []).map((sp, idx) => ({
|
const sellingPoints = (brief.selling_points ?? []).map((sp, idx) => ({
|
||||||
id: `sp-${idx}`,
|
id: `sp-${idx}`,
|
||||||
content: sp.content,
|
content: sp.content,
|
||||||
required: sp.required,
|
required: sp.required ?? (sp.priority === 'core'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Map blacklist words
|
// Map blacklist words
|
||||||
@@ -233,12 +238,21 @@ export default function TaskBriefPage() {
|
|||||||
loadBriefData()
|
loadBriefData()
|
||||||
}, [loadBriefData])
|
}, [loadBriefData])
|
||||||
|
|
||||||
const handleDownload = (file: AgencyBriefFile) => {
|
const handleDownload = async (file: AgencyBriefFile) => {
|
||||||
|
if (USE_MOCK || !file.url) {
|
||||||
toast.info(`下载文件: ${file.name}`)
|
toast.info(`下载文件: ${file.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.downloadFile(file.url, file.name)
|
||||||
|
} catch {
|
||||||
|
toast.error('下载失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadAll = () => {
|
const handleDownloadAll = () => {
|
||||||
toast.info('下载全部文件')
|
if (!viewModel) return
|
||||||
|
viewModel.files.forEach(f => handleDownload(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading || !viewModel) {
|
if (loading || !viewModel) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||||
import { useSSE } from '@/contexts/SSEContext'
|
import { useSSE } from '@/contexts/SSEContext'
|
||||||
import type { TaskResponse, AIReviewResult } from '@/types/task'
|
import type { TaskResponse, AIReviewResult, ReviewDimensions, SellingPointMatchResult, BriefMatchDetail } from '@/types/task'
|
||||||
import type { BriefResponse } from '@/types/brief'
|
import type { BriefResponse } from '@/types/brief'
|
||||||
|
|
||||||
// 前端 UI 使用的任务阶段类型
|
// 前端 UI 使用的任务阶段类型
|
||||||
@@ -57,6 +57,15 @@ type TaskData = {
|
|||||||
rejectionReason?: string
|
rejectionReason?: string
|
||||||
submittedAt?: string
|
submittedAt?: string
|
||||||
scriptContent?: string
|
scriptContent?: string
|
||||||
|
aiResult?: {
|
||||||
|
score: number
|
||||||
|
dimensions?: ReviewDimensions
|
||||||
|
sellingPointMatches?: SellingPointMatchResult[]
|
||||||
|
briefMatchDetail?: BriefMatchDetail
|
||||||
|
violations: Array<{ type: string; content: string; suggestion: string; dimension?: string }>
|
||||||
|
}
|
||||||
|
agencyReview?: { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
|
||||||
|
brandReview?: { result: 'approved' | 'rejected'; comment: string; reviewer: string; time: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgencyBriefFile = {
|
type AgencyBriefFile = {
|
||||||
@@ -134,8 +143,9 @@ function mapApiTaskToTaskData(task: TaskResponse): TaskData {
|
|||||||
// 提取 AI 审核结果中的 issues
|
// 提取 AI 审核结果中的 issues
|
||||||
const aiResult = phase === 'script' ? task.script_ai_result : task.video_ai_result
|
const aiResult = phase === 'script' ? task.script_ai_result : task.video_ai_result
|
||||||
if (aiResult?.violations) {
|
if (aiResult?.violations) {
|
||||||
|
const dimLabels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
|
||||||
issues = aiResult.violations.map(v => ({
|
issues = aiResult.violations.map(v => ({
|
||||||
title: v.type,
|
title: v.dimension ? `[${dimLabels[v.dimension] || v.dimension}] ${v.type}` : v.type,
|
||||||
description: `${v.content}${v.suggestion ? ` — ${v.suggestion}` : ''}`,
|
description: `${v.content}${v.suggestion ? ` — ${v.suggestion}` : ''}`,
|
||||||
timestamp: v.timestamp ? `${v.timestamp}s` : undefined,
|
timestamp: v.timestamp ? `${v.timestamp}s` : undefined,
|
||||||
severity: v.severity === 'warning' ? 'warning' as const : 'error' as const,
|
severity: v.severity === 'warning' ? 'warning' as const : 'error' as const,
|
||||||
@@ -144,6 +154,35 @@ function mapApiTaskToTaskData(task: TaskResponse): TaskData {
|
|||||||
|
|
||||||
const subtitle = `${task.project.name} · ${task.project.brand_name || ''}`
|
const subtitle = `${task.project.name} · ${task.project.brand_name || ''}`
|
||||||
|
|
||||||
|
// AI 审核结果(完整,含维度)
|
||||||
|
const aiResultData = aiResult ? {
|
||||||
|
score: aiResult.score,
|
||||||
|
dimensions: aiResult.dimensions,
|
||||||
|
sellingPointMatches: aiResult.selling_point_matches,
|
||||||
|
briefMatchDetail: aiResult.brief_match_detail,
|
||||||
|
violations: aiResult.violations.map(v => ({ type: v.type, content: v.content, suggestion: v.suggestion, dimension: v.dimension })),
|
||||||
|
} : undefined
|
||||||
|
|
||||||
|
// 代理商审核反馈
|
||||||
|
const agencyStatus = phase === 'script' ? task.script_agency_status : task.video_agency_status
|
||||||
|
const agencyComment = phase === 'script' ? task.script_agency_comment : task.video_agency_comment
|
||||||
|
const agencyReview = agencyStatus && agencyStatus !== 'pending' ? {
|
||||||
|
result: (agencyStatus === 'passed' || agencyStatus === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
|
||||||
|
comment: agencyComment || '',
|
||||||
|
reviewer: task.agency?.name || '代理商',
|
||||||
|
time: task.updated_at,
|
||||||
|
} : undefined
|
||||||
|
|
||||||
|
// 品牌方审核反馈
|
||||||
|
const brandStatus = phase === 'script' ? task.script_brand_status : task.video_brand_status
|
||||||
|
const brandComment = phase === 'script' ? task.script_brand_comment : task.video_brand_comment
|
||||||
|
const brandReview = brandStatus && brandStatus !== 'pending' ? {
|
||||||
|
result: (brandStatus === 'passed' || brandStatus === 'force_passed' ? 'approved' : 'rejected') as 'approved' | 'rejected',
|
||||||
|
comment: brandComment || '',
|
||||||
|
reviewer: '品牌方审核员',
|
||||||
|
time: task.updated_at,
|
||||||
|
} : undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.name,
|
title: task.name,
|
||||||
@@ -153,6 +192,9 @@ function mapApiTaskToTaskData(task: TaskResponse): TaskData {
|
|||||||
issues: issues.length > 0 ? issues : undefined,
|
issues: issues.length > 0 ? issues : undefined,
|
||||||
rejectionReason,
|
rejectionReason,
|
||||||
submittedAt,
|
submittedAt,
|
||||||
|
aiResult: aiResultData,
|
||||||
|
agencyReview,
|
||||||
|
brandReview,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,11 +206,12 @@ const mockBriefData = {
|
|||||||
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
|
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
|
||||||
] as AgencyBriefFile[],
|
] as AgencyBriefFile[],
|
||||||
sellingPoints: [
|
sellingPoints: [
|
||||||
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
|
{ id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const },
|
||||||
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
|
{ id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const },
|
||||||
{ id: 'sp3', content: '延展性好,易推开', required: false },
|
{ id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const },
|
||||||
{ id: 'sp4', content: '适合敏感肌', required: false },
|
{ id: 'sp4', content: '适合敏感肌', priority: 'recommended' as const },
|
||||||
{ id: 'sp5', content: '夏日必备防晒', required: true },
|
{ id: 'sp5', content: '夏日必备防晒', priority: 'core' as const },
|
||||||
|
{ id: 'sp6', content: '产品成分天然', priority: 'reference' as const },
|
||||||
],
|
],
|
||||||
blacklistWords: [
|
blacklistWords: [
|
||||||
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
|
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
|
||||||
@@ -278,15 +321,16 @@ function ReviewProgressBar({ task }: { task: TaskData }) {
|
|||||||
// Brief 组件
|
// Brief 组件
|
||||||
function AgencyBriefSection({ toast, briefData }: {
|
function AgencyBriefSection({ toast, briefData }: {
|
||||||
toast: ReturnType<typeof useToast>
|
toast: ReturnType<typeof useToast>
|
||||||
briefData: { files: AgencyBriefFile[]; sellingPoints: { id: string; content: string; required: boolean }[]; blacklistWords: { id: string; word: string; reason: string }[] }
|
briefData: { files: AgencyBriefFile[]; sellingPoints: { id: string; content: string; priority: 'core' | 'recommended' | 'reference' }[]; blacklistWords: { id: string; word: string; reason: string }[] }
|
||||||
}) {
|
}) {
|
||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
|
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
|
||||||
|
|
||||||
const handleDownload = (file: AgencyBriefFile) => { toast.info(`下载文件: ${file.name}`) }
|
const handleDownload = (file: AgencyBriefFile) => { toast.info(`下载文件: ${file.name}`) }
|
||||||
|
|
||||||
const requiredPoints = briefData.sellingPoints.filter(sp => sp.required)
|
const corePoints = briefData.sellingPoints.filter(sp => sp.priority === 'core')
|
||||||
const optionalPoints = briefData.sellingPoints.filter(sp => !sp.required)
|
const recommendedPoints = briefData.sellingPoints.filter(sp => sp.priority === 'recommended')
|
||||||
|
const referencePoints = briefData.sellingPoints.filter(sp => sp.priority === 'reference')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -337,21 +381,31 @@ function AgencyBriefSection({ toast, briefData }: {
|
|||||||
<Target className="w-4 h-4 text-accent-green" /> 卖点要求
|
<Target className="w-4 h-4 text-accent-green" /> 卖点要求
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{requiredPoints.length > 0 && (
|
{corePoints.length > 0 && (
|
||||||
<div className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
|
<div className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
|
||||||
<p className="text-xs text-accent-coral font-medium mb-2">必选卖点(必须提及)</p>
|
<p className="text-xs text-accent-coral font-medium mb-2">核心卖点(建议优先提及)</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{requiredPoints.map((sp) => (
|
{corePoints.map((sp) => (
|
||||||
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded-lg">{sp.content}</span>
|
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded-lg">{sp.content}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{optionalPoints.length > 0 && (
|
{recommendedPoints.length > 0 && (
|
||||||
<div className="p-3 bg-bg-elevated rounded-xl">
|
<div className="p-3 bg-accent-amber/10 rounded-xl border border-accent-amber/30">
|
||||||
<p className="text-xs text-text-tertiary font-medium mb-2">可选卖点</p>
|
<p className="text-xs text-accent-amber font-medium mb-2">推荐卖点(建议提及)</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{optionalPoints.map((sp) => (
|
{recommendedPoints.map((sp) => (
|
||||||
|
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-amber/20 text-accent-amber rounded-lg">{sp.content}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{referencePoints.length > 0 && (
|
||||||
|
<div className="p-3 bg-bg-elevated rounded-xl">
|
||||||
|
<p className="text-xs text-text-tertiary font-medium mb-2">参考信息</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{referencePoints.map((sp) => (
|
||||||
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded-lg">{sp.content}</span>
|
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded-lg">{sp.content}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -394,42 +448,290 @@ function AgencyBriefSection({ toast, briefData }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UploadView({ task, toast, briefData }: { task: TaskData; toast: ReturnType<typeof useToast>; briefData: typeof mockBriefData }) {
|
function FileUploadSection({ taskId, phase, onUploaded }: { taskId: string; phase: 'script' | 'video'; onUploaded: () => void }) {
|
||||||
const router = useRouter()
|
const [file, setFile] = useState<File | null>(null)
|
||||||
const { id } = useParams()
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const isScript = task.phase === 'script'
|
const [progress, setProgress] = useState(0)
|
||||||
const uploadPath = isScript ? `/creator/task/${id}/script` : `/creator/task/${id}/video`
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
const toast = useToast()
|
||||||
|
const isScript = phase === 'script'
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
router.push(uploadPath)
|
const selectedFile = e.target.files?.[0]
|
||||||
|
if (selectedFile) { setFile(selectedFile); setUploadError(null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!file) return
|
||||||
|
setIsUploading(true); setProgress(0); setUploadError(null)
|
||||||
|
try {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
for (let i = 0; i <= 100; i += 20) { await new Promise(r => setTimeout(r, 400)); setProgress(i) }
|
||||||
|
toast.success(isScript ? '脚本已提交,等待 AI 审核' : '视频已提交,等待 AI 审核')
|
||||||
|
onUploaded()
|
||||||
|
} else {
|
||||||
|
const result = await api.proxyUpload(file, isScript ? 'script' : 'video', (pct) => {
|
||||||
|
setProgress(Math.min(90, Math.round(pct * 0.9)))
|
||||||
|
})
|
||||||
|
setProgress(95)
|
||||||
|
if (isScript) {
|
||||||
|
await api.uploadTaskScript(taskId, { file_url: result.url, file_name: result.file_name })
|
||||||
|
} else {
|
||||||
|
await api.uploadTaskVideo(taskId, { file_url: result.url, file_name: result.file_name })
|
||||||
|
}
|
||||||
|
setProgress(100)
|
||||||
|
toast.success(isScript ? '脚本已提交,等待 AI 审核' : '视频已提交,等待 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptTypes = isScript ? '.doc,.docx,.pdf,.txt,.xls,.xlsx' : '.mp4,.mov,.avi,.mkv'
|
||||||
|
const acceptHint = isScript ? '支持 Word、PDF、TXT、Excel 格式' : '支持 MP4/MOV 格式,≤ 100MB'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card rounded-2xl card-shadow">
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle">
|
||||||
|
<Upload className="w-5 h-5 text-accent-indigo" />
|
||||||
|
<span className="text-base font-semibold text-text-primary">{isScript ? '上传脚本' : '上传视频'}</span>
|
||||||
|
<span className="ml-auto px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-indigo/15 text-accent-indigo">待提交</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{!file ? (
|
||||||
|
<label className="border-2 border-dashed border-border-subtle rounded-xl p-8 text-center hover:border-accent-indigo/50 transition-colors cursor-pointer block">
|
||||||
|
<Upload className="w-8 h-8 mx-auto text-text-tertiary mb-3" />
|
||||||
|
<p className="text-text-secondary mb-1">点击选择{isScript ? '脚本' : '视频'}文件</p>
|
||||||
|
<p className="text-xs text-text-tertiary">{acceptHint}</p>
|
||||||
|
<input type="file" accept={acceptTypes} onChange={handleFileChange} className="hidden" />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border-subtle rounded-xl 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 className="w-4 h-4 animate-spin text-accent-indigo flex-shrink-0" />
|
||||||
|
) : uploadError ? (
|
||||||
|
<AlertTriangle className="w-4 h-4 text-accent-coral flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-4 h-4 text-accent-green flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<FileText className="w-4 h-4 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); setUploadError(null) }} className="p-1 hover:bg-bg-elevated rounded">
|
||||||
|
<XCircle className="w-4 h-4 text-text-tertiary" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isUploading && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<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
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!file || isUploading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isUploading ? <><Loader2 className="w-5 h-5 animate-spin" />上传中 {progress}%</> : <><Upload className="w-5 h-5" />{isScript ? '提交脚本' : '提交视频'}</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDimensionLabel(key: string) {
|
||||||
|
const labels: Record<string, string> = { legal: '法规合规', platform: '平台规则', brand_safety: '品牌安全', brief_match: 'Brief 匹配' }
|
||||||
|
return labels[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
function AIResultDetailSection({ task }: { task: TaskData }) {
|
||||||
|
if (!task.aiResult) return null
|
||||||
|
const { dimensions, sellingPointMatches, briefMatchDetail, violations } = task.aiResult
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card rounded-2xl card-shadow">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border-subtle">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="w-5 h-5 text-accent-indigo" />
|
||||||
|
<span className="text-base font-semibold text-text-primary">AI 审核结果</span>
|
||||||
|
</div>
|
||||||
|
<span className={cn('text-xl font-bold', task.aiResult.score >= 85 ? 'text-accent-green' : task.aiResult.score >= 70 ? 'text-yellow-400' : 'text-accent-coral')}>
|
||||||
|
{task.aiResult.score}分
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{dimensions && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{(['legal', 'platform', 'brand_safety', 'brief_match'] as const).map(key => {
|
||||||
|
const dim = dimensions[key]
|
||||||
|
if (!dim) return null
|
||||||
|
return (
|
||||||
|
<div key={key} className={cn('p-3 rounded-xl border', dim.passed ? 'bg-accent-green/5 border-accent-green/20' : 'bg-accent-coral/5 border-accent-coral/20')}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs text-text-secondary">{getDimensionLabel(key)}</span>
|
||||||
|
{dim.passed ? <CheckCircle className="w-4 h-4 text-accent-green" /> : <XCircle className="w-4 h-4 text-accent-coral" />}
|
||||||
|
</div>
|
||||||
|
<span className={cn('text-lg font-bold', dim.passed ? (dim.score >= 85 ? 'text-accent-green' : 'text-yellow-400') : 'text-accent-coral')}>{dim.score}</span>
|
||||||
|
{dim.issue_count > 0 && <span className="text-xs text-text-tertiary ml-1">({dim.issue_count} 项问题)</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{violations.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-accent-coral" /> 违规检测 ({violations.length})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{violations.map((v, idx) => (
|
||||||
|
<div key={idx} className="p-3 bg-accent-coral/10 rounded-xl border border-accent-coral/30">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="px-2 py-0.5 rounded text-xs font-semibold bg-accent-coral/15 text-accent-coral">{v.type}</span>
|
||||||
|
{v.dimension && <span className="text-xs text-text-tertiary">{getDimensionLabel(v.dimension)}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-primary">「{v.content}」</p>
|
||||||
|
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Brief 匹配度详情 */}
|
||||||
|
{briefMatchDetail && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
||||||
|
<Target className="w-4 h-4 text-accent-indigo" /> Brief 匹配度分析
|
||||||
|
</h4>
|
||||||
|
<div className="p-3 bg-bg-elevated rounded-xl space-y-3">
|
||||||
|
{/* 评分说明 */}
|
||||||
|
<p className="text-sm text-text-secondary">{briefMatchDetail.explanation}</p>
|
||||||
|
{/* 覆盖率进度条 */}
|
||||||
|
{briefMatchDetail.total_points > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-text-tertiary">卖点覆盖率</span>
|
||||||
|
<span className="text-text-primary font-medium">{briefMatchDetail.matched_points}/{briefMatchDetail.required_points > 0 ? briefMatchDetail.required_points : briefMatchDetail.total_points} 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-bg-page rounded-full overflow-hidden">
|
||||||
|
<div className={cn('h-full rounded-full transition-all', briefMatchDetail.coverage_score >= 80 ? 'bg-accent-green' : briefMatchDetail.coverage_score >= 50 ? 'bg-accent-amber' : 'bg-accent-coral')} style={{ width: `${briefMatchDetail.coverage_score}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 亮点 */}
|
||||||
|
{briefMatchDetail.highlights.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-accent-green font-medium mb-1">亮点</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{briefMatchDetail.highlights.map((h, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-accent-green flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-text-secondary">{h}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 问题点 */}
|
||||||
|
{briefMatchDetail.issues.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-accent-coral font-medium mb-1">可改进</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{briefMatchDetail.issues.map((issue, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-accent-coral flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-text-secondary">{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 卖点匹配列表 */}
|
||||||
|
{sellingPointMatches && sellingPointMatches.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
||||||
|
<Target className="w-4 h-4 text-accent-green" /> 卖点匹配详情
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sellingPointMatches.map((sp, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-2 p-2.5 rounded-xl bg-bg-elevated">
|
||||||
|
{sp.matched ? <CheckCircle className="w-4 h-4 text-accent-green flex-shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 text-accent-coral flex-shrink-0 mt-0.5" />}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-text-primary">{sp.content}</span>
|
||||||
|
<span className={cn('px-1.5 py-0.5 text-xs rounded',
|
||||||
|
sp.priority === 'core' ? 'bg-accent-coral/20 text-accent-coral' :
|
||||||
|
sp.priority === 'recommended' ? 'bg-accent-amber/20 text-accent-amber' :
|
||||||
|
'bg-bg-page text-text-tertiary'
|
||||||
|
)}>{sp.priority === 'core' ? '核心' : sp.priority === 'recommended' ? '推荐' : '参考'}</span>
|
||||||
|
</div>
|
||||||
|
{sp.evidence && <p className="text-xs text-text-tertiary mt-0.5">{sp.evidence}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewFeedbackCard({ review, type }: { review: { result: string; comment: string; reviewer: string; time: string }; type: 'agency' | 'brand' }) {
|
||||||
|
const isApproved = review.result === 'approved'
|
||||||
|
const title = type === 'agency' ? '代理商审核意见' : '品牌方终审意见'
|
||||||
|
return (
|
||||||
|
<div className={cn('bg-bg-card rounded-2xl card-shadow border', isApproved ? 'border-accent-green/30' : 'border-accent-coral/30')}>
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-border-subtle">
|
||||||
|
{isApproved ? <CheckCircle className="w-5 h-5 text-accent-green" /> : <XCircle className="w-5 h-5 text-accent-coral" />}
|
||||||
|
<span className="text-base font-semibold text-text-primary">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-medium text-text-primary">{review.reviewer}</span>
|
||||||
|
<span className={cn('px-2 py-0.5 rounded text-xs font-semibold', isApproved ? 'bg-accent-green/15 text-accent-green' : 'bg-accent-coral/15 text-accent-coral')}>
|
||||||
|
{isApproved ? '通过' : '驳回'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{review.comment && <p className="text-sm text-text-secondary">{review.comment}</p>}
|
||||||
|
<p className="text-xs text-text-tertiary mt-2">{review.time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadView({ task, toast, briefData, onUploaded }: { task: TaskData; toast: ReturnType<typeof useToast>; briefData: typeof mockBriefData; onUploaded: () => void }) {
|
||||||
|
const isScript = task.phase === 'script'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 h-full">
|
<div className="flex flex-col gap-6 h-full">
|
||||||
{isScript && <AgencyBriefSection toast={toast} briefData={briefData} />}
|
{isScript && <AgencyBriefSection toast={toast} briefData={briefData} />}
|
||||||
<div className="flex items-center justify-between">
|
<FileUploadSection taskId={task.id} phase={task.phase} onUploaded={onUploaded} />
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{isScript ? '上传脚本' : '上传视频'}</h3>
|
|
||||||
<p className="text-sm text-text-tertiary">{isScript ? '支持粘贴文本或上传文档' : '支持 MP4/MOV 格式,≤ 100MB'}</p>
|
|
||||||
</div>
|
|
||||||
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-indigo/15 text-accent-indigo">待提交</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex-1 flex flex-col items-center justify-center gap-5 rounded-2xl border-2 border-dashed transition-colors card-shadow bg-bg-card min-h-[400px] border-border-subtle hover:border-accent-indigo/50 cursor-pointer"
|
|
||||||
onClick={handleUploadClick}
|
|
||||||
>
|
|
||||||
<div className="w-20 h-20 rounded-full bg-accent-indigo/15 flex items-center justify-center">
|
|
||||||
<Upload className="w-10 h-10 text-accent-indigo" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
|
||||||
<p className="text-lg font-semibold text-text-primary">点击进入上传页面</p>
|
|
||||||
<p className="text-sm text-text-tertiary">{isScript ? '支持 .doc、.docx、.txt 格式' : '支持 MP4/MOV 格式,≤ 100MB'}</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={handleUploadClick} className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-gradient-to-r from-accent-indigo to-[#4F46E5] text-white font-semibold hover:opacity-90 transition-opacity">
|
|
||||||
<Upload className="w-5 h-5" />
|
|
||||||
{isScript ? '上传脚本文档' : '上传视频文件'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -489,20 +791,20 @@ function AIReviewingView({ task }: { task: TaskData }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => void }) {
|
function RejectionView({ task, onAppeal, onReupload }: { task: TaskData; onAppeal: () => void; onReupload: () => void }) {
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
switch (task.stage) {
|
switch (task.stage) {
|
||||||
case 'ai_result': return 'AI 审核结果'
|
case 'ai_result': return 'AI 审核结果'
|
||||||
case 'agency_rejected': return '代理商审核结果'
|
case 'agency_rejected': return '代理商审核驳回'
|
||||||
case 'brand_rejected': return '品牌方审核结果'
|
case 'brand_rejected': return '品牌方审核驳回'
|
||||||
default: return '审核结果'
|
default: return '审核结果'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const getStatusText = () => {
|
const getStatusText = () => {
|
||||||
switch (task.stage) {
|
switch (task.stage) {
|
||||||
case 'ai_result': return 'AI 检测到问题'
|
case 'ai_result': return 'AI 检测到问题,请修改后重新上传'
|
||||||
case 'agency_rejected': return '代理商审核驳回'
|
case 'agency_rejected': return '代理商审核驳回,请根据意见修改'
|
||||||
case 'brand_rejected': return '品牌方审核驳回'
|
case 'brand_rejected': return '品牌方审核驳回,请根据意见修改'
|
||||||
default: return '需要修改'
|
default: return '需要修改'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,7 +812,7 @@ function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => voi
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 h-full">
|
<div className="flex flex-col gap-6 h-full">
|
||||||
<ReviewProgressBar task={task} />
|
<ReviewProgressBar task={task} />
|
||||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex-1 flex flex-col">
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
<div className="flex items-center gap-3 pb-5 border-b border-border-subtle">
|
<div className="flex items-center gap-3 pb-5 border-b border-border-subtle">
|
||||||
<div className="w-12 h-12 rounded-xl bg-accent-coral/15 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-xl bg-accent-coral/15 flex items-center justify-center">
|
||||||
<XCircle className="w-6 h-6 text-accent-coral" />
|
<XCircle className="w-6 h-6 text-accent-coral" />
|
||||||
@@ -525,35 +827,19 @@ function RejectionView({ task, onAppeal }: { task: TaskData; onAppeal: () => voi
|
|||||||
<p className="text-sm text-text-secondary leading-relaxed">{task.rejectionReason}</p>
|
<p className="text-sm text-text-secondary leading-relaxed">{task.rejectionReason}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{task.issues && task.issues.length > 0 && (
|
<div className="flex items-center justify-between pt-4">
|
||||||
<div className="py-4 flex flex-col gap-4 flex-1">
|
|
||||||
<span className="text-sm font-semibold text-text-primary">发现 {task.issues.length} 处问题</span>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{task.issues.map((issue, index) => (
|
|
||||||
<div key={index} className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={cn('px-2 py-0.5 rounded text-xs font-semibold',
|
|
||||||
issue.severity === 'error' ? 'bg-accent-coral/15 text-accent-coral' : 'bg-amber-500/15 text-amber-500'
|
|
||||||
)}>
|
|
||||||
{issue.severity === 'error' ? '违规' : '建议'}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-text-primary">{issue.title}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-text-secondary leading-relaxed">{issue.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-border-subtle">
|
|
||||||
<button type="button" onClick={onAppeal} className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium hover:bg-bg-page transition-colors">
|
<button type="button" onClick={onAppeal} className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-bg-elevated border border-border-subtle text-text-secondary text-sm font-medium hover:bg-bg-page transition-colors">
|
||||||
<MessageCircle className="w-[18px] h-[18px]" /> 申诉
|
<MessageCircle className="w-[18px] h-[18px]" /> 申诉
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold hover:bg-accent-green/90 transition-colors">
|
<button type="button" onClick={onReupload} className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-accent-green text-white text-sm font-semibold hover:bg-accent-green/90 transition-colors">
|
||||||
<Upload className="w-[18px] h-[18px]" /> 重新上传
|
<Upload className="w-[18px] h-[18px]" /> 重新上传
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{task.stage === 'agency_rejected' && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
|
||||||
|
{task.stage === 'brand_rejected' && task.brandReview && <ReviewFeedbackCard review={task.brandReview} type="brand" />}
|
||||||
|
{task.stage === 'brand_rejected' && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
|
||||||
|
<AIResultDetailSection task={task} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -567,9 +853,14 @@ function WaitingReviewView({ task }: { task: TaskData }) {
|
|||||||
<div className="flex flex-col gap-6 h-full">
|
<div className="flex flex-col gap-6 h-full">
|
||||||
<ReviewProgressBar task={task} />
|
<ReviewProgressBar task={task} />
|
||||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<FileText className="w-5 h-5 text-text-secondary" />
|
<div className="w-12 h-12 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
|
||||||
<span className="text-base font-semibold text-text-primary">{task.phase === 'script' ? '脚本提交信息' : '视频提交信息'}</span>
|
<Clock className="w-6 h-6 text-accent-indigo" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-lg font-semibold text-text-primary">{title}</span>
|
||||||
|
<span className="text-sm text-text-secondary">{description}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
|
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -600,28 +891,8 @@ function WaitingReviewView({ task }: { task: TaskData }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow flex-1">
|
{!isAgency && task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<AIResultDetailSection task={task} />
|
||||||
<div className="w-12 h-12 rounded-xl bg-accent-indigo/15 flex items-center justify-center">
|
|
||||||
<Clock className="w-6 h-6 text-accent-indigo" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-lg font-semibold text-text-primary">{title}</span>
|
|
||||||
<span className="text-sm text-text-secondary">{description}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-accent-indigo/10 rounded-xl p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-accent-indigo flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-sm font-medium text-text-primary">温馨提示</span>
|
|
||||||
<span className="text-[13px] text-text-secondary">
|
|
||||||
{isAgency ? '代理商通常会在 1-2 个工作日内完成审核。' : '品牌方终审通常需要 1-3 个工作日。'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -631,30 +902,6 @@ function ApprovedView({ task }: { task: TaskData }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 h-full">
|
<div className="flex flex-col gap-6 h-full">
|
||||||
<ReviewProgressBar task={task} />
|
<ReviewProgressBar task={task} />
|
||||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<FileText className="w-5 h-5 text-text-secondary" />
|
|
||||||
<span className="text-base font-semibold text-text-primary">{task.phase === 'script' ? '脚本提交信息' : '视频提交信息'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-bg-elevated rounded-xl p-4 flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">提交时间</span><span className="text-sm text-text-primary">{task.submittedAt || '2026-02-01 10:30'}</span></div>
|
|
||||||
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">AI审核</span><span className="text-sm text-accent-green font-medium">已通过</span></div>
|
|
||||||
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">代理商审核</span><span className="text-sm text-accent-green font-medium">已通过</span></div>
|
|
||||||
<div className="flex items-center justify-between"><span className="text-sm text-text-tertiary">品牌方终审</span><span className="text-sm text-accent-green font-medium">已通过</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<CheckCircle className="w-6 h-6 text-accent-green" />
|
|
||||||
<span className="text-lg font-semibold text-text-primary">品牌方审核通过</span>
|
|
||||||
</div>
|
|
||||||
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-accent-green/15 text-accent-green">已通过</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
{isVideoPhase ? '恭喜!视频已通过所有审核,可以发布了' : '脚本已通过品牌方终审,请继续上传视频'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
<div className="bg-bg-card rounded-2xl p-6 card-shadow">
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<div className="w-12 h-12 rounded-xl bg-accent-green/15 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-xl bg-accent-green/15 flex items-center justify-center">
|
||||||
@@ -677,6 +924,9 @@ function ApprovedView({ task }: { task: TaskData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{task.brandReview && <ReviewFeedbackCard review={task.brandReview} type="brand" />}
|
||||||
|
{task.agencyReview && <ReviewFeedbackCard review={task.agencyReview} type="agency" />}
|
||||||
|
<AIResultDetailSection task={task} />
|
||||||
{!isVideoPhase && (
|
{!isVideoPhase && (
|
||||||
<div className="flex justify-center pt-4">
|
<div className="flex justify-center pt-4">
|
||||||
<button type="button" className="flex items-center gap-2 px-12 py-4 rounded-xl bg-accent-green text-white text-base font-semibold">
|
<button type="button" className="flex items-center gap-2 px-12 py-4 rounded-xl bg-accent-green text-white text-base font-semibold">
|
||||||
@@ -701,6 +951,7 @@ export default function TaskDetailPage() {
|
|||||||
const [briefData, setBriefData] = useState(mockBriefData)
|
const [briefData, setBriefData] = useState(mockBriefData)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showReupload, setShowReupload] = useState(false)
|
||||||
|
|
||||||
const loadTask = useCallback(async () => {
|
const loadTask = useCallback(async () => {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
@@ -728,7 +979,7 @@ export default function TaskDetailPage() {
|
|||||||
sellingPoints: (brief.selling_points || []).map((sp, i) => ({
|
sellingPoints: (brief.selling_points || []).map((sp, i) => ({
|
||||||
id: `sp-${i}`,
|
id: `sp-${i}`,
|
||||||
content: sp.content,
|
content: sp.content,
|
||||||
required: sp.required,
|
priority: (sp.priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference',
|
||||||
})),
|
})),
|
||||||
blacklistWords: (brief.blacklist_words || []).map((bw, i) => ({
|
blacklistWords: (brief.blacklist_words || []).map((bw, i) => ({
|
||||||
id: `bw-${i}`,
|
id: `bw-${i}`,
|
||||||
@@ -762,6 +1013,14 @@ export default function TaskDetailPage() {
|
|||||||
return () => { unsub1(); unsub2() }
|
return () => { unsub1(); unsub2() }
|
||||||
}, [subscribe, taskId, loadTask])
|
}, [subscribe, taskId, loadTask])
|
||||||
|
|
||||||
|
// AI 审核中时轮询(SSE 后备方案)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskData || (taskData.stage !== 'ai_reviewing') || USE_MOCK) return
|
||||||
|
const interval = setInterval(() => { loadTask() }, 5000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [taskData?.stage, loadTask])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<ResponsiveLayout role="creator">
|
<ResponsiveLayout role="creator">
|
||||||
@@ -793,12 +1052,27 @@ export default function TaskDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
// 驳回状态下选择重新上传时,显示上传界面
|
||||||
|
if (showReupload && (taskData.stage === 'ai_result' || taskData.stage === 'agency_rejected' || taskData.stage === 'brand_rejected')) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" onClick={() => setShowReupload(false)} className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-bg-elevated text-text-secondary text-sm hover:bg-bg-card transition-colors">
|
||||||
|
<ArrowLeft className="w-4 h-4" /> 返回审核详情
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{taskData.phase === 'script' && <AgencyBriefSection toast={toast} briefData={briefData} />}
|
||||||
|
<FileUploadSection taskId={taskData.id} phase={taskData.phase} onUploaded={() => { setShowReupload(false); loadTask() }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
switch (taskData.stage) {
|
switch (taskData.stage) {
|
||||||
case 'upload': return <UploadView task={taskData} toast={toast} briefData={briefData} />
|
case 'upload': return <UploadView task={taskData} toast={toast} briefData={briefData} onUploaded={loadTask} />
|
||||||
case 'ai_reviewing': return <AIReviewingView task={taskData} />
|
case 'ai_reviewing': return <AIReviewingView task={taskData} />
|
||||||
case 'ai_result':
|
case 'ai_result':
|
||||||
case 'agency_rejected':
|
case 'agency_rejected':
|
||||||
case 'brand_rejected': return <RejectionView task={taskData} onAppeal={handleAppeal} />
|
case 'brand_rejected': return <RejectionView task={taskData} onAppeal={handleAppeal} onReupload={() => setShowReupload(true)} />
|
||||||
case 'agency_reviewing':
|
case 'agency_reviewing':
|
||||||
case 'brand_reviewing': return <WaitingReviewView task={taskData} />
|
case 'brand_reviewing': return <WaitingReviewView task={taskData} />
|
||||||
case 'brand_approved': return <ApprovedView task={taskData} />
|
case 'brand_approved': return <ApprovedView task={taskData} />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user