Merge branch 'main' of gitee.com:wg123/video-gen

This commit is contained in:
Lrd
2026-06-24 11:17:38 +08:00
36 changed files with 2281 additions and 274 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ __pycache__/
.claude/
.vscode/
.trae/
video-gen-app/dist/
# video-gen-app/dist/
video-gen-api/dist/
# 使用通配符
+2
View File
@@ -40,6 +40,8 @@ from app.models.material_cost import MaterialCost # noqa: F401
from app.models.generated_resource import GeneratedResource # noqa: F401
from app.models.pre_test_template import PreTestTemplate # noqa: F401
from app.models.upload_task import UploadTask # noqa: F401
from app.models.open_type import OpenType # noqa: F401
config = context.config
@@ -0,0 +1,29 @@
"""新增开户方式管理表
Revision ID: 2bafebb4be14
Revises: 08749ddd1414
Create Date: 2026-06-23 13:50:26.577410
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2bafebb4be14'
down_revision: Union[str, None] = '08749ddd1414'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
+4
View File
@@ -22,6 +22,8 @@ from app.api.v1.user_oauth import router as user_oauth_router
from app.api.v1.user_oauth_app import router as user_oauth_app_router
from app.api.v1.upload_material import router as upload_material_router
from app.api.v1.pre_test_template import router as pre_test_template_router
from app.api.v1.material_consumption import router as material_consumption_router
from app.api.v1.open_type import router as open_type_router
from app.api.admin import router as admin_module_router
api_router = APIRouter()
@@ -47,4 +49,6 @@ api_router.include_router(user_oauth_router)
api_router.include_router(user_oauth_app_router)
api_router.include_router(upload_material_router)
api_router.include_router(pre_test_template_router)
api_router.include_router(material_consumption_router)
api_router.include_router(open_type_router)
api_router.include_router(admin_module_router)
@@ -0,0 +1,254 @@
from typing import Any, Optional, List
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query, Depends, Body
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.resources_material import ResourcesMaterial
from app.models.material_cost import MaterialCost
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount
from app.dependencies import get_current_user, get_db, get_admin_user
from app.services.material_consumption_queue import sync_all_advertisers_consumption, _fetch_and_save_consumption
from app.services.material_consumption_service import get_consumption_list, format_consumption_response
router = APIRouter(prefix="/material-consumption", tags=["material-consumption"])
@router.get(
"/list",
summary="查询素材消耗列表",
description="查询当前用户的素材消耗列表",
)
async def user_consumption_list(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
advertiser_id: Optional[str] = Query(None, description="广告主ID筛选"),
consume_date: Optional[List[str]] = Query(None, description="消耗日期范围,格式: ['开始日期','结束日期']"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
consumptions, total = await get_consumption_list(
db=db,
page=page,
page_size=page_size,
advertiser_id=advertiser_id,
consume_date=consume_date,
current_user=current_user,
)
return {
"code": 0,
"data": format_consumption_response(consumptions),
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
@router.get(
"/sync",
summary="手动同步素材消耗",
description="手动触发当前用户授权的广告主的素材消耗同步任务,加入队列顺序执行",
)
async def sync_consumption(
date: str = Query(None, description="同步日期,默认为昨天"),
advertiser_id: Optional[str] = Query(None, description="指定广告主ID,不指定则同步所有授权的广告主"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
if date is None:
date = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
from app.services.material_consumption_queue import material_consumption_queue
query = (
select(UserOAuth.id, UserOAuthAccount.advertiser_id)
.join(UserOAuthAccount, UserOAuth.id == UserOAuthAccount.oauth_id)
.join(ResourcesMaterial, UserOAuthAccount.advertiser_id == ResourcesMaterial.advertiser_id)
.where(
UserOAuth.user_id == current_user.id,
UserOAuth.deleted_at.is_(None),
UserOAuthAccount.deleted_at.is_(None),
ResourcesMaterial.deleted_at.is_(None),
ResourcesMaterial.material_id.is_not_(None),
)
.distinct()
)
if advertiser_id:
query = query.where(UserOAuthAccount.advertiser_id == advertiser_id)
result = await db.execute(query)
oauth_advertiser_pairs = result.all()
if not oauth_advertiser_pairs:
return {
"code": 0,
"message": "没有找到可同步的广告主",
}
for oauth_id, adv_id in oauth_advertiser_pairs:
await material_consumption_queue.enqueue({
"oauth_id": oauth_id,
"advertiser_id": adv_id,
"date": date,
})
return {
"code": 0,
"message": f"已将 {len(oauth_advertiser_pairs)} 个广告主的消耗更新任务加入队列",
}
@router.get(
"/fields",
summary="获取素材消耗字段描述",
description="返回素材消耗表每个字段对应的中文描述,帮助前端理解字段含义",
)
async def get_consumption_fields() -> Any | dict:
fields = [
{"field": "id", "description": "主键"},
{"field": "advertiser_id", "description": "广告主id"},
{"field": "material_id", "description": "素材id"},
{"field": "consume_date", "description": "消耗日期"},
{"field": "stat_cost", "description": "消耗金额"},
{"field": "show_cnt", "description": "展示数"},
{"field": "cpm_platform", "description": "平均千次展现费用(元)"},
{"field": "click_cnt", "description": "点击数"},
{"field": "ctr", "description": "点击率"},
{"field": "cpc_platform", "description": "平均点击单价(元)"},
{"field": "convert_cnt", "description": "转化数"},
{"field": "conversion_cost", "description": "平均转化成本(元)"},
{"field": "conversion_rate", "description": "转化率"},
{"field": "deep_convert_cnt", "description": "深度转化数"},
{"field": "deep_convert_cost", "description": "深度转化成本(元)"},
{"field": "deep_convert_rate", "description": "深度转化率"},
{"field": "active", "description": "激活数"},
{"field": "active_cost", "description": "激活成本(元)"},
{"field": "active_rate", "description": "激活率"},
{"field": "active_register", "description": "注册数"},
{"field": "active_register_cost", "description": "注册成本(元)"},
{"field": "active_register_rate", "description": "注册率"},
{"field": "attribution_next_day_open_cnt", "description": "次留数"},
{"field": "attribution_next_day_open_cost", "description": "次留成本"},
{"field": "attribution_next_day_open_rate", "description": "次留率"},
{"field": "active_pay", "description": "首次付费数"},
{"field": "active_pay_cost", "description": "首次付费成本(元)"},
{"field": "active_pay_rate", "description": "首次付费率"},
{"field": "phone", "description": "点击电话按钮"},
{"field": "form", "description": "用户在门店落地页多线沟通提交表单的次数"},
{"field": "download_start", "description": "用户点击下载开始的次数"},
{"field": "form_submit", "description": "用户查看附加创意后,提交表单的次数"},
{"field": "button", "description": "用户点击按钮button的次数"},
{"field": "view", "description": "用户在关键页面的浏览次数"},
{"field": "message", "description": "用户点击短信咨询的次数"},
{"field": "consult", "description": "用户点击在线咨询按钮的次数"},
{"field": "consult_effective", "description": "用户在门店落地页多线沟通的在线咨询中有效咨询的次数"},
{"field": "shopping", "description": "用户购买商品的次数"},
{"field": "customer_effective", "description": "有效获客"},
{"field": "attribution_game_in_app_ltv_1day", "description": "当日付费金额"},
{"field": "attribution_game_in_app_roi_1day", "description": "当日付费ROI"},
{"field": "loan_completion", "description": "完件数(互联网金融-贷款行业中,用户成功提交贷款额度申请的行为)"},
{"field": "loan_completion_cost", "description": "完件成本(元)"},
{"field": "loan_completion_rate", "description": "完件率"},
{"field": "loan_credit", "description": "授信数(互联网金融-贷款行业中,用户提交贷款额度申请后,客户审批通过,给予用户可贷款的额度)"},
{"field": "loan_credit_cost", "description": "授信成本(元)"},
{"field": "loan_credit_rate", "description": "授信率"},
{"field": "in_app_order_gmv", "description": "引流电商订单GMV(当您使用\"in_app_order\"事件回传订单金额时,对应的GMV金额)"},
{"field": "in_app_order_roi", "description": "引流电商订单ROI"},
{"field": "in_app_pay_gmv", "description": "引流电商支付GMV"},
{"field": "in_app_pay_roi", "description": "引流电商支付ROI"},
{"field": "total_play", "description": "播放量(播放时间大于0S的数量,在某些蜂窝网络环境下,需要您手动点击开始才会开始播放,因此有时播放数小于展示数)"},
{"field": "valid_play", "description": "有效播放数"},
{"field": "valid_play_cost", "description": "有效播放成本(元)"},
{"field": "valid_play_rate", "description": "有效播放率"},
{"field": "valid_play_of_mille", "description": "千次有效播放数"},
{"field": "valid_play_cost_of_mille", "description": "千次有效播放成本(元)"},
{"field": "average_play_time_per_play", "description": "平均单次播放时长"},
{"field": "play_over_rate", "description": "完播率"},
{"field": "dy_like", "description": "点赞数"},
{"field": "dy_comment", "description": "评论量"},
{"field": "dy_share", "description": "分享量"},
{"field": "report_cnt", "description": "举报数"},
{"field": "created_at", "description": "创建时间"},
]
return {
"code": 0,
"data": fields,
}
@router.get(
"/advertisers",
summary="获取用户的广告主列表",
description="获取当前用户有权限的广告主列表,用于筛选",
)
async def get_advertisers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
result = await db.execute(
select(UserOAuthAccount.advertiser_id)
.join(UserOAuth, UserOAuthAccount.oauth_id == UserOAuth.id)
.where(
UserOAuth.user_id == current_user.id,
UserOAuth.deleted_at.is_(None),
UserOAuthAccount.deleted_at.is_(None),
)
.distinct()
)
advertisers = result.scalars().all()
return {
"code": 0,
"data": [{"advertiser_id": str(aid)} for aid in advertisers],
}
@router.get(
"/admin/list",
summary="管理员查询所有素材消耗列表",
description="管理员可查看系统中所有用户的素材消耗数据",
)
async def admin_consumption_list(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
advertiser_id: Optional[str] = Query(None, description="广告主ID筛选"),
user_id: Optional[str] = Query(None, description="用户ID筛选"),
consume_date: Optional[List[str]] = Query(None, description="消耗日期范围,格式: ['开始日期','结束日期']"),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
#需要判断管理员吗
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有管理员才能查询所有素材消耗列表",
)
consumptions, total = await get_consumption_list(
db=db,
page=page,
page_size=page_size,
advertiser_id=advertiser_id,
user_id=user_id,
consume_date=consume_date,
current_user=None,
)
return {
"code": 0,
"data": format_consumption_response(consumptions, include_oauth_id=True),
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
},
}
+293
View File
@@ -0,0 +1,293 @@
from typing import Any, Optional, List
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.models.open_type import OpenType
from app.schemas.open_type import (
OpenTypeCreate,
OpenTypeUpdate,
OpenTypeListResponse,
OpenTypeResponse,
)
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/open-type", tags=["open-type"])
# 公共接口 - 无需登录
@router.get(
"/open_type_all",
summary="查询所有开户方式",
description="查询所有开户方式,前端使用select框选择,无需登录",
dependencies=[],
)
async def get_open_type_public(
db: AsyncSession = Depends(get_db),
) -> Any | dict:
result = await db.execute(
select(OpenType).where(OpenType.deleted_at.is_(None))
)
open_types = result.scalars().all()
return {
"code": 0,
"message": "查询成功",
"data": [
{
"id": ot.id,
"type_name": ot.type_name,
"open_type": ot.open_type,
"description": ot.description,
"thumb": ot.thumb,
}
for ot in open_types
],
}
@router.get(
"/list",
summary="获取开户方式列表",
description="获取开户方式列表,支持分页和筛选",
response_model=OpenTypeListResponse,
)
async def get_open_type_list(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
type_name: Optional[str] = Query(None, description="标题名称筛选"),
open_type: Optional[int] = Query(None, description="开户方式id筛选"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
offset = (page - 1) * page_size
query = select(OpenType).where(OpenType.deleted_at.is_(None))
if type_name:
query = query.where(OpenType.type_name.like(f"%{type_name}%"))
if open_type:
query = query.where(OpenType.open_type == open_type)
result = await db.execute(
query.order_by(OpenType.open_type.asc())
.offset(offset)
.limit(page_size)
)
open_types = result.scalars().all()
count_query = select(func.count(OpenType.id)).where(OpenType.deleted_at.is_(None))
if type_name:
count_query = count_query.where(OpenType.type_name.like(f"%{type_name}%"))
if open_type:
count_query = count_query.where(OpenType.open_type == open_type)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
return {
"code": 0,
"message": "查询成功",
"data": [
{
"id": item.id,
"type_name": item.type_name,
"open_type": item.open_type,
"description": item.description,
"thumb": item.thumb,
"created_at": item.created_at,
"updated_at": item.updated_at,
}
for item in open_types
],
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": (total + page_size - 1) // page_size,
},
}
@router.get(
"/select/{id}",
summary="获取开户方式详情",
description="根据ID获取开户方式详情",
response_model=OpenTypeResponse,
)
async def get_open_type_detail(
id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
result = await db.execute(
select(OpenType).where(OpenType.id == id, OpenType.deleted_at.is_(None))
)
open_type = result.scalar_one_or_none()
if not open_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="开户方式不存在",
)
return {
"code": 0,
"message": "查询成功",
"data": {
"id": open_type.id,
"type_name": open_type.type_name,
"open_type": open_type.open_type,
"description": open_type.description,
"thumb": open_type.thumb,
"created_at": open_type.created_at,
"updated_at": open_type.updated_at,
},
}
@router.post(
"/create",
summary="创建开户方式",
description="创建新的开户方式",
response_model=OpenTypeResponse,
)
async def create_open_type(
req: OpenTypeCreate = Body(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
existing = await db.execute(
select(OpenType).where(OpenType.open_type == req.open_type, OpenType.deleted_at.is_(None))
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"开户方式ID {req.open_type} 已存在",
)
open_type = OpenType(
id=generate_id(),
type_name=req.type_name,
open_type=req.open_type,
description=req.description,
thumb=req.thumb,
)
db.add(open_type)
await db.commit()
await db.refresh(open_type)
return {
"code": 0,
"message": "创建成功",
"data": {
"id": open_type.id,
"type_name": open_type.type_name,
"open_type": open_type.open_type,
"description": open_type.description,
"thumb": open_type.thumb,
"created_at": open_type.created_at,
"updated_at": open_type.updated_at,
},
}
@router.put(
"/update/{id}",
summary="更新开户方式",
description="更新指定的开户方式",
response_model=OpenTypeResponse,
)
async def update_open_type(
id: str,
req: OpenTypeUpdate = Body(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
result = await db.execute(
select(OpenType).where(OpenType.id == id, OpenType.deleted_at.is_(None))
)
open_type = result.scalar_one_or_none()
if not open_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="开户方式不存在",
)
if req.open_type is not None and req.open_type != open_type.open_type:
existing = await db.execute(
select(OpenType).where(
OpenType.open_type == req.open_type,
OpenType.id != id,
OpenType.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"开户方式ID {req.open_type} 已存在",
)
if req.type_name is not None:
open_type.type_name = req.type_name
if req.open_type is not None:
open_type.open_type = req.open_type
if req.description is not None:
open_type.description = req.description
if req.thumb is not None:
open_type.thumb = req.thumb
await db.commit()
await db.refresh(open_type)
return {
"code": 0,
"message": "更新成功",
"data": {
"id": open_type.id,
"type_name": open_type.type_name,
"open_type": open_type.open_type,
"description": open_type.description,
"thumb": open_type.thumb,
"created_at": open_type.created_at,
"updated_at": open_type.updated_at,
},
}
@router.delete(
"/delete/{id}",
summary="删除开户方式",
description="软删除指定的开户方式",
response_model=OpenTypeResponse,
)
async def delete_open_type(
id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
result = await db.execute(
select(OpenType).where(OpenType.id == id, OpenType.deleted_at.is_(None))
)
open_type = result.scalar_one_or_none()
if not open_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="开户方式不存在",
)
open_type.deleted_at = func.now()
await db.commit()
return {
"code": 0,
"message": "删除成功",
"data": None,
}
@@ -222,6 +222,14 @@ async def async_batch_upload_material(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any | dict:
# param = {
# "account_ids" : json.dumps([1836693172153543]),
# }
# account_info = await DouyinApi().get_account_info("0019eb9f130027c05b8", param)
# return account_info
if not req.tasks:
return {
"code": 0,
+10 -6
View File
@@ -68,10 +68,13 @@ async def lifespan(app: FastAPI):
await upload_queue.recover()
upload_queue_task = asyncio.create_task(upload_queue.run())
# 启动上传队列(异步处理素材上传)
from app.services.upload_queue import upload_queue
await upload_queue.recover()
upload_queue_task = asyncio.create_task(upload_queue.run())
# 启动素材消耗队列
from app.services.material_consumption_queue import material_consumption_queue
consumption_queue_task = asyncio.create_task(material_consumption_queue.run())
# 启动素材消耗计划任务(每天9点自动同步)
from app.tasks.material_consumption_task import schedule_daily_sync
consumption_schedule_task = asyncio.create_task(schedule_daily_sync())
# 启动时立即同步一次未支付订单
asyncio.create_task(asyncio.sleep(5)) # 等待5秒后再同步,让系统完全启动
@@ -97,10 +100,11 @@ async def lifespan(app: FastAPI):
await queue_task
upload_queue.stop()
await upload_queue_task
material_consumption_queue.stop()
await consumption_queue_task
consumption_schedule_task.cancel()
expiry_task.cancel()
token_refresh_task.cancel()
upload_queue.stop()
await upload_queue_task
await close_database()
await close_redis()
+24
View File
@@ -0,0 +1,24 @@
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class OpenType(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "open_type"
id: Mapped[str] = mapped_column(
String(32), primary_key=True, comment="主键"
)
type_name: Mapped[str] = mapped_column(
String(128), nullable=False, comment="标题名称"
)
open_type: Mapped[int] = mapped_column(
Integer, nullable=False, unique=True, index=True, comment="开户方式id"
)
description: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="开户方式描述"
)
thumb: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="缩略图"
)
+48
View File
@@ -0,0 +1,48 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class OpenTypeCreate(BaseModel):
type_name: str = Field(..., description="标题名称")
open_type: int = Field(..., description="开户方式id")
description: Optional[str] = Field(None, description="开户方式描述")
thumb: Optional[str] = Field(None, description="缩略图")
class OpenTypeUpdate(BaseModel):
type_name: Optional[str] = Field(None, description="标题名称")
open_type: Optional[int] = Field(None, description="开户方式id")
description: Optional[str] = Field(None, description="开户方式描述")
thumb: Optional[str] = Field(None, description="缩略图")
class OpenTypeOut(BaseModel):
id: str = Field(..., description="主键")
type_name: str = Field(..., description="标题名称")
open_type: int = Field(..., description="开户方式id")
description: Optional[str] = Field(None, description="开户方式描述")
thumb: Optional[str] = Field(None, description="缩略图")
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
class PaginationInfo(BaseModel):
page: int = Field(..., description="当前页码")
page_size: int = Field(..., description="每页数量")
total: int = Field(..., description="总记录数")
total_pages: int = Field(..., description="总页数")
class OpenTypeListResponse(BaseModel):
code: int = Field(0, description="返回码,0表示成功")
message: str = Field("查询成功", description="返回消息")
data: List[OpenTypeOut] = Field(..., description="开户方式列表")
pagination: PaginationInfo = Field(..., description="分页信息")
class OpenTypeResponse(BaseModel):
code: int = Field(0, description="返回码,0表示成功")
message: str = Field("操作成功", description="返回消息")
data: Optional[OpenTypeOut] = Field(None, description="开户方式信息")
@@ -0,0 +1,334 @@
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.base import async_session
from app.models.material_cost import MaterialCost
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount
from app.models.resources_material import ResourcesMaterial
from app.utils.id_gen import generate_id
from app.utils.douyinApi import DouyinApi
import os
import json
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
os.makedirs(LOG_DIR, exist_ok=True)
logger = logging.getLogger("material_consumption_task")
logger.setLevel(logging.INFO)
class DailyRotatingFileHandler(logging.FileHandler):
def __init__(self, directory, encoding=None):
self.directory = directory
filename = self._get_log_filename()
super().__init__(filename, encoding=encoding)
def _get_log_filename(self):
return os.path.join(self.directory, f"material_consumption-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.log")
def emit(self, record):
current_filename = self._get_log_filename()
if self.baseFilename != current_filename:
self.close()
self.baseFilename = current_filename
self.stream = self._open()
super().emit(record)
if not logger.handlers:
handler = DailyRotatingFileHandler(LOG_DIR, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S"))
logger.addHandler(handler)
douyin_api = DouyinApi()
def _convert_numeric(value):
"""Convert string numeric values to appropriate Python types."""
if value is None or value == "":
return None
try:
# Try int first
return int(value)
except ValueError:
try:
# Then try float
return float(value)
except ValueError:
# Return original value if conversion fails
return None
class MaterialConsumptionQueue:
def __init__(self):
self.queue: asyncio.Queue[dict] = asyncio.Queue()
self.running = False
async def enqueue(self, task_data: dict):
"""Add a task to the queue."""
await self.queue.put(task_data)
async def run(self):
"""Main processing loop."""
self.running = True
logger.info("Material consumption queue started")
while self.running:
try:
task_data = await asyncio.wait_for(self.queue.get(), timeout=5.0)
except asyncio.TimeoutError:
continue
try:
await self._process(task_data)
except Exception as e:
logger.error(f"Error processing material consumption task: {e}")
finally:
self.queue.task_done()
logger.info("Material consumption queue stopped")
async def _process(self, task_data: dict):
"""Process a single consumption update task."""
oauth_id = task_data.get("oauth_id")
advertiser_id = task_data.get("advertiser_id")
date = task_data.get("date")
logger.info(f"Processing consumption update for oauth_id={oauth_id}, advertiser_id={advertiser_id}, date={date}")
try:
result = await _fetch_and_save_consumption(oauth_id, advertiser_id, date)
if result.get("success"):
logger.info(f"Successfully updated consumption for advertiser {advertiser_id} on {date}")
else:
logger.error(f"Failed to update consumption for advertiser {advertiser_id} on {date}: {result.get('error')}")
except Exception as e:
logger.error(f"Exception processing consumption for advertiser {advertiser_id} on {date}: {e}")
def stop(self):
"""Stop the queue."""
self.running = False
async def _fetch_and_save_consumption(oauth_id: str, advertiser_id: str, date: str) -> dict:
async with async_session() as db:
try:
consume_date_obj = datetime.strptime(date, "%Y-%m-%d").date()
# 查询当前授权下系统上传的素材ID列表
material_result = await db.execute(
select(ResourcesMaterial.upload_id)
.where(
ResourcesMaterial.oauth_id == oauth_id,
ResourcesMaterial.advertiser_id == advertiser_id,
ResourcesMaterial.deleted_at.is_(None),
ResourcesMaterial.upload_id.isnot(None),
ResourcesMaterial.upload_id != "",
)
)
system_material_ids = [str(row[0]) for row in material_result.all()]
if not system_material_ids:
return {
"success": True,
"message": "没有系统上传的素材"
}
#强制删除旧数据,真实删除,不要软删除
await db.execute(
delete(MaterialCost).where(
MaterialCost.oauth_id == oauth_id,
MaterialCost.advertiser_id == advertiser_id,
MaterialCost.consume_date == consume_date_obj,
MaterialCost.deleted_at.is_(None),
)
)
BATCH_SIZE = 50
total_saved = 0
total_requests = 0
# 分批请求,每次请求50个素材
for i in range(0, len(system_material_ids), BATCH_SIZE):
batch_materials = system_material_ids[i:i+BATCH_SIZE]
total_requests += 1
params = {
"dimensions": json.dumps(["ad_platform_material_name","image_mode","material_id","stat_time_day"]),
"advertiser_id": int(advertiser_id),
"metrics": json.dumps([
"stat_cost","show_cnt","cpm_platform","click_cnt","ctr","cpc_platform","convert_cnt","conversion_cost","conversion_rate","deep_convert_cnt","deep_convert_cost","deep_convert_rate","click_start_cnt","click_start_cost","download_finish_rate",
"install_finish_cnt","install_finish_cost","install_finish_rate","active","active_cost",
"active_rate","active_register","active_register_cost","active_register_rate","game_addiction",
"game_addiction_cost","game_addiction_rate","attribution_next_day_open_cnt","attribution_next_day_open_cost","attribution_next_day_open_rate","next_day_open","active_pay","active_pay_cost","active_pay_rate",
"game_pay_count","game_pay_cost","in_app_uv","in_app_detail_uv","in_app_cart","in_app_pay",
"in_app_order","attribution_billing_game_in_app_ltv_1day","attribution_billing_game_in_app_roi_1day","phone","form",
"form_submit","map","button","view","download_start","qq","vote","lottery","message",
"redirect","shopping","consult","consult_effective","phone_confirm","phone_connect","phone_effective",
"redirect_to_shop","coupon_single_page","poi_address_click","poi_collect","customer_effective",
"attribution_customer_effective","attribution_customer_effective_cost","attribution_clue_pay_succeed",
"attribution_clue_pay_succeed_cost","attribution_clue_interflow","attribution_clue_interflow_cost","attribution_clue_high_intention","attribution_clue_high_intention_cost",
"consult_clue","clue_message_count","attribution_work_wechat_unfriend_count","attribution_clue_connected_count","attribution_clue_connected_cost","attribution_clue_connected_rate","attribution_form","form_and_submit_count","form_and_submit_cost","intention_form_and_submit_count","intention_form_and_submit_cost","attribution_micro_game_0d_ltv","attribution_micro_game_3d_ltv","attribution_micro_game_7d_ltv","attribution_micro_game_0d_roi","attribution_micro_game_3d_roi","attribution_micro_game_7d_roi","attribution_game_in_app_ltv_1day","attribution_game_in_app_roi_1day","active_pay_intra_day_count","active_pay_intra_day_cost","active_pay_intra_day_rate","first_pay_intra_24hour_amount","loan_completion","loan_completion_cost","loan_completion_rate","pre_loan_credit","pre_loan_credit_cost","loan_credit_cost","loan_credit","loan_credit_rate","in_wechat_pay_count","unfollow_in_wechat_count","loan","loan_cost","loan_rate","open_account_count","withdraw_m2_count","in_app_order_gmv","in_app_order_roi","in_app_pay_gmv","in_app_pay_roi","first_rental_order_count","commute_first_pay_count","first_order_count","submit_certification_count","approval_count","total_play","play_duration_3s","valid_play","valid_play_cost","valid_play_rate","valid_play_of_mille","valid_play_cost_of_mille","average_play_time_per_play","play_over_rate","dy_like","dy_comment","dy_share","report_cnt","location_click","dislike_cnt","dy_home_visited","dy_follow","message_action","click_landing_page","click_shopwindow","click_website","click_call_dy","click_download","luban_live_enter_cnt","luban_live_follow_cnt","luban_live_share_cnt","luban_live_comment_cnt","luban_live_gift_cnt","luban_live_gift_amount","click_call_cnt","click_counsel","a3_ask_count","a3_ask_cost"
]),
"filters": json.dumps([
{
"field": "stat_cost",
"type": 3,
"operator": 4,
"values": ["0"]
},
{
"field": "material_id",
"type": 2,
"operator": 7,
"values": batch_materials
}
]),
"start_time": date,
"end_time": date,
"order_by": json.dumps([
{
"field": "stat_cost",
"type": "ASC"
}
])
}
response = await douyin_api.get_material_cost(oauth_id, params, request_count=3)
if response.get("code") != 0:
logger.warning(f"获取素材消耗失败(批次 {i//BATCH_SIZE + 1}): {response.get('message', '未知错误')}")
continue
data = response.get("data", {})
list_data = data.get("rows", [])
for item in list_data:
dimensions = item.get("dimensions", {})
metrics = item.get("metrics", {})
material_id = dimensions.get("material_id", "")
cost = MaterialCost(
id=generate_id(),
oauth_id=oauth_id,
advertiser_id=str(advertiser_id),
material_id=material_id,
consume_date=consume_date_obj,
stat_cost=_convert_numeric(metrics.get("stat_cost")),
show_cnt=_convert_numeric(metrics.get("show_cnt")),
cpm_platform=_convert_numeric(metrics.get("cpm_platform")),
click_cnt=_convert_numeric(metrics.get("click_cnt")),
ctr=_convert_numeric(metrics.get("ctr")),
cpc_platform=_convert_numeric(metrics.get("cpc_platform")),
convert_cnt=_convert_numeric(metrics.get("convert_cnt")),
conversion_cost=_convert_numeric(metrics.get("conversion_cost")),
conversion_rate=_convert_numeric(metrics.get("conversion_rate")),
deep_convert_cnt=_convert_numeric(metrics.get("deep_convert_cnt")),
deep_convert_cost=_convert_numeric(metrics.get("deep_convert_cost")),
deep_convert_rate=_convert_numeric(metrics.get("deep_convert_rate")),
active=_convert_numeric(metrics.get("active")),
active_cost=_convert_numeric(metrics.get("active_cost")),
active_rate=_convert_numeric(metrics.get("active_rate")),
active_register=_convert_numeric(metrics.get("active_register")),
active_register_cost=_convert_numeric(metrics.get("active_register_cost")),
active_register_rate=_convert_numeric(metrics.get("active_register_rate")),
attribution_next_day_open_cnt=_convert_numeric(metrics.get("attribution_next_day_open_cnt")),
attribution_next_day_open_cost=_convert_numeric(metrics.get("attribution_next_day_open_cost")),
attribution_next_day_open_rate=_convert_numeric(metrics.get("attribution_next_day_open_rate")),
active_pay=_convert_numeric(metrics.get("active_pay")),
active_pay_cost=_convert_numeric(metrics.get("active_pay_cost")),
active_pay_rate=_convert_numeric(metrics.get("active_pay_rate")),
phone=_convert_numeric(metrics.get("phone")),
form=_convert_numeric(metrics.get("form")),
download_start=_convert_numeric(metrics.get("download_start")),
form_submit=_convert_numeric(metrics.get("form_submit")),
button=_convert_numeric(metrics.get("button")),
view=_convert_numeric(metrics.get("view")),
message=_convert_numeric(metrics.get("message")),
consult=_convert_numeric(metrics.get("consult")),
consult_effective=_convert_numeric(metrics.get("consult_effective")),
shopping=_convert_numeric(metrics.get("shopping")),
customer_effective=_convert_numeric(metrics.get("customer_effective")),
attribution_game_in_app_ltv_1day=_convert_numeric(metrics.get("attribution_game_in_app_ltv_1day")),
attribution_game_in_app_roi_1day=_convert_numeric(metrics.get("attribution_game_in_app_roi_1day")),
loan_completion=_convert_numeric(metrics.get("loan_completion")),
loan_completion_cost=_convert_numeric(metrics.get("loan_completion_cost")),
loan_completion_rate=_convert_numeric(metrics.get("loan_completion_rate")),
loan_credit=_convert_numeric(metrics.get("loan_credit")),
loan_credit_cost=_convert_numeric(metrics.get("loan_credit_cost")),
loan_credit_rate=_convert_numeric(metrics.get("loan_credit_rate")),
in_app_order_gmv=_convert_numeric(metrics.get("in_app_order_gmv")),
in_app_order_roi=_convert_numeric(metrics.get("in_app_order_roi")),
in_app_pay_gmv=_convert_numeric(metrics.get("in_app_pay_gmv")),
in_app_pay_roi=_convert_numeric(metrics.get("in_app_pay_roi")),
total_play=_convert_numeric(metrics.get("total_play")),
valid_play=_convert_numeric(metrics.get("valid_play")),
valid_play_cost=_convert_numeric(metrics.get("valid_play_cost")),
valid_play_rate=_convert_numeric(metrics.get("valid_play_rate")),
valid_play_of_mille=_convert_numeric(metrics.get("valid_play_of_mille")),
valid_play_cost_of_mille=_convert_numeric(metrics.get("valid_play_cost_of_mille")),
average_play_time_per_play=_convert_numeric(metrics.get("average_play_time_per_play")),
play_over_rate=_convert_numeric(metrics.get("play_over_rate")),
dy_like=_convert_numeric(metrics.get("dy_like")),
dy_comment=_convert_numeric(metrics.get("dy_comment")),
dy_share=_convert_numeric(metrics.get("dy_share")),
report_cnt=_convert_numeric(metrics.get("report_cnt")),
)
db.add(cost)
total_saved += 1
await db.commit()
return {
"success": True,
"message": f"成功更新 {total_saved} 条记录,共请求 {total_requests} 批次,系统素材总数 {len(system_material_ids)}",
"count": total_saved
}
except Exception as e:
await db.rollback()
return {
"success": False,
"error": str(e)
}
#异步获取所有要更新的广告主,素材消耗
async def sync_all_advertisers_consumption(date: str = None):
"""Sync consumption data for all advertisers."""
if date is None:
date = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
async with async_session() as db:
result = await db.execute(
select(UserOAuth.id, UserOAuthAccount.advertiser_id)
.join(UserOAuthAccount, UserOAuth.id == UserOAuthAccount.oauth_id)
.join(ResourcesMaterial, UserOAuthAccount.oauth_id == ResourcesMaterial.oauth_id)
.where(
UserOAuth.deleted_at.is_(None),
UserOAuthAccount.deleted_at.is_(None),
ResourcesMaterial.deleted_at.is_(None),
ResourcesMaterial.material_id.isnot(None),
ResourcesMaterial.material_id != "",
)
.distinct()
)
oauth_advertiser_pairs = result.all()
for oauth_id, advertiser_id in oauth_advertiser_pairs:
await material_consumption_queue.enqueue({
"oauth_id": oauth_id,
"advertiser_id": advertiser_id,
"date": date,
})
logger.info(f"Enqueued consumption sync for oauth_id={oauth_id}, advertiser_id={advertiser_id}, date={date}")
return {"message": f"已将 {len(oauth_advertiser_pairs)} 个广告主的消耗更新任务加入队列"}
material_consumption_queue = MaterialConsumptionQueue()
@@ -0,0 +1,160 @@
from typing import Any, Optional, Tuple, List
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.material_cost import MaterialCost
from app.models.user_oauth import UserOAuth
async def get_consumption_list(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
advertiser_id: Optional[str] = None,
user_id: Optional[str] = None,
consume_date: Optional[List[str]] = None,
current_user: Optional[User] = None,
) -> Tuple[List[MaterialCost], int]:
offset = (page - 1) * page_size
query = select(MaterialCost).where(MaterialCost.deleted_at.is_(None))
if current_user:
query = query.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
).where(
UserOAuth.user_id == current_user.id,
UserOAuth.deleted_at.is_(None),
)
if user_id:
query = query.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
).where(UserOAuth.user_id == user_id)
if advertiser_id:
query = query.where(MaterialCost.advertiser_id == advertiser_id)
if consume_date and len(consume_date) >= 2:
start_date_obj = datetime.strptime(consume_date[0], "%Y-%m-%d").date()
end_date_obj = datetime.strptime(consume_date[1], "%Y-%m-%d").date()
query = query.where(MaterialCost.consume_date >= start_date_obj)
query = query.where(MaterialCost.consume_date <= end_date_obj)
result = await db.execute(
query.order_by(MaterialCost.consume_date.desc())
.offset(offset)
.limit(page_size)
)
consumptions = result.scalars().all()
count_query = select(func.count(MaterialCost.id)).where(MaterialCost.deleted_at.is_(None))
if current_user:
count_query = count_query.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
).where(
UserOAuth.user_id == current_user.id,
UserOAuth.deleted_at.is_(None),
)
if user_id:
count_query = count_query.join(
UserOAuth,
MaterialCost.oauth_id == UserOAuth.id,
).where(UserOAuth.user_id == user_id)
if advertiser_id:
count_query = count_query.where(MaterialCost.advertiser_id == advertiser_id)
if consume_date and len(consume_date) >= 2:
start_date_obj = datetime.strptime(consume_date[0], "%Y-%m-%d").date()
end_date_obj = datetime.strptime(consume_date[1], "%Y-%m-%d").date()
count_query = count_query.where(MaterialCost.consume_date >= start_date_obj)
count_query = count_query.where(MaterialCost.consume_date <= end_date_obj)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
return consumptions, total
def format_consumption_response(consumptions: List[MaterialCost], include_oauth_id: bool = False) -> List[dict]:
result = []
for consumption in consumptions:
item = {
"id": consumption.id,
"advertiser_id": consumption.advertiser_id,
"material_id": consumption.material_id,
"consume_date": consumption.consume_date.isoformat() if consumption.consume_date else None,
"stat_cost": consumption.stat_cost,
"show_cnt": consumption.show_cnt,
"cpm_platform": consumption.cpm_platform,
"click_cnt": consumption.click_cnt,
"ctr": consumption.ctr,
"cpc_platform": consumption.cpc_platform,
"convert_cnt": consumption.convert_cnt,
"conversion_cost": consumption.conversion_cost,
"conversion_rate": consumption.conversion_rate,
"deep_convert_cnt": consumption.deep_convert_cnt,
"deep_convert_cost": consumption.deep_convert_cost,
"deep_convert_rate": consumption.deep_convert_rate,
"active": consumption.active,
"active_cost": consumption.active_cost,
"active_rate": consumption.active_rate,
"active_register": consumption.active_register,
"active_register_cost": consumption.active_register_cost,
"active_register_rate": consumption.active_register_rate,
"attribution_next_day_open_cnt": consumption.attribution_next_day_open_cnt,
"attribution_next_day_open_cost": consumption.attribution_next_day_open_cost,
"attribution_next_day_open_rate": consumption.attribution_next_day_open_rate,
"active_pay": consumption.active_pay,
"active_pay_cost": consumption.active_pay_cost,
"active_pay_rate": consumption.active_pay_rate,
"phone": consumption.phone,
"form": consumption.form,
"download_start": consumption.download_start,
"form_submit": consumption.form_submit,
"button": consumption.button,
"view": consumption.view,
"message": consumption.message,
"consult": consumption.consult,
"consult_effective": consumption.consult_effective,
"shopping": consumption.shopping,
"customer_effective": consumption.customer_effective,
"attribution_game_in_app_ltv_1day": consumption.attribution_game_in_app_ltv_1day,
"attribution_game_in_app_roi_1day": consumption.attribution_game_in_app_roi_1day,
"loan_completion": consumption.loan_completion,
"loan_completion_cost": consumption.loan_completion_cost,
"loan_completion_rate": consumption.loan_completion_rate,
"loan_credit": consumption.loan_credit,
"loan_credit_cost": consumption.loan_credit_cost,
"loan_credit_rate": consumption.loan_credit_rate,
"in_app_order_gmv": consumption.in_app_order_gmv,
"in_app_order_roi": consumption.in_app_order_roi,
"in_app_pay_gmv": consumption.in_app_pay_gmv,
"in_app_pay_roi": consumption.in_app_pay_roi,
"total_play": consumption.total_play,
"valid_play": consumption.valid_play,
"valid_play_cost": consumption.valid_play_cost,
"valid_play_rate": consumption.valid_play_rate,
"valid_play_of_mille": consumption.valid_play_of_mille,
"valid_play_cost_of_mille": consumption.valid_play_cost_of_mille,
"average_play_time_per_play": consumption.average_play_time_per_play,
"play_over_rate": consumption.play_over_rate,
"dy_like": consumption.dy_like,
"dy_comment": consumption.dy_comment,
"dy_share": consumption.dy_share,
"report_cnt": consumption.report_cnt,
"created_at": consumption.created_at,
}
if include_oauth_id:
item["oauth_id"] = consumption.oauth_id
result.append(item)
return result
@@ -10,11 +10,13 @@ from app.models.upload_task import UploadTask
from app.models.generated_resource import GeneratedResource
from app.models.user_oauth import UserOAuth
from app.models.resources_material import ResourcesMaterial
from app.models.user_oauth_account import UserOAuthAccount
from app.utils.id_gen import generate_id
from app.utils.douyinApi import DouyinApi
import os
import hashlib
import json
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
@@ -133,6 +135,51 @@ class UploadQueue:
note=result.get("message", "上传成功"),
)
)
#请求巨量接口获取账户信息,如果存在则更新,否则插入新记录
param = {
"account_ids" : json.dumps([int(task.advertiser_id)]),
}
try:
account_info = await douyin_api.get_account_info(task.oauth_id, param)
code = account_info.get("code", 0)
try:
code = int(code)
except (ValueError, TypeError):
code = -1
if code != 0:
logger.error(f"Error getting account info: {json.dumps(account_info)}")
else:
existing_account = await db.execute(
select(UserOAuthAccount).where(
UserOAuthAccount.oauth_id == task.oauth_id,
UserOAuthAccount.advertiser_id == task.advertiser_id,
)
)
existing_account = existing_account.scalar_one_or_none()
if existing_account:
await db.execute(
update(UserOAuthAccount).where(
UserOAuthAccount.id == existing_account.id,
).values(
deleted_at=None,
advertiser_name= account_info.get("data", {}).get("account_detail_list", [{}])[0].get("advertiser_name", ""),
advertiser_role= "",
)
)
else:
db.add(UserOAuthAccount(
id=generate_id(),
oauth_id=task.oauth_id,
advertiser_id=task.advertiser_id,
advertiser_name= account_info.get("data", {}).get("account_detail_list", [{}])[0].get("advertiser_name", ""),
advertiser_role= "",
))
except Exception as e:
logger.error(f"Exception getting account info: {e}")
else:
await db.execute(
update(UploadTask).where(UploadTask.id == task_id).values(
@@ -0,0 +1,68 @@
from datetime import datetime, timedelta, timezone
import asyncio
import os
import logging
from app.services.material_consumption_queue import sync_all_advertisers_consumption
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
os.makedirs(LOG_DIR, exist_ok=True)
logger = logging.getLogger("material_consumption_task")
logger.setLevel(logging.INFO)
class DailyRotatingFileHandler(logging.FileHandler):
def __init__(self, directory, encoding=None):
self.directory = directory
filename = self._get_log_filename()
super().__init__(filename, encoding=encoding)
def _get_log_filename(self):
return os.path.join(self.directory, f"material_consumption_task-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.log")
def emit(self, record):
current_filename = self._get_log_filename()
if self.baseFilename != current_filename:
self.close()
self.baseFilename = current_filename
self.stream = self._open()
super().emit(record)
if not logger.handlers:
handler = DailyRotatingFileHandler(LOG_DIR, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S"))
logger.addHandler(handler)
async def schedule_daily_sync():
"""每天9点自动同步素材消耗数据"""
logger.info("Daily material consumption sync task started")
while True:
try:
now = datetime.now(timezone.utc)
target_time = now.replace(hour=9, minute=0, second=0, microsecond=0)
if now >= target_time:
target_time += timedelta(days=1)
wait_seconds = (target_time - now).total_seconds()
logger.info(f"Waiting {wait_seconds/3600:.1f} hours until next sync at {target_time}")
await asyncio.sleep(wait_seconds)
logger.info("Starting daily material consumption sync...")
try:
result = await sync_all_advertisers_consumption()
logger.info(f"Daily sync completed: {result['message']}")
except Exception as e:
logger.error(f"Daily sync failed: {e}")
except asyncio.CancelledError:
logger.info("Daily sync task cancelled")
break
except Exception as e:
logger.error(f"Error in schedule_daily_sync: {e}")
await asyncio.sleep(60)
+24
View File
@@ -78,4 +78,28 @@ class DouyinApi:
url,
'POST',
{"json": params or {}}
)
#获取素材消耗
async def get_material_cost(self, oauth_id: str, params: any, request_count: int = 3) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://api.oceanengine.com/open_api/v3.0/report/custom/get/"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}},
request_count=request_count
)
#获取账户信息
async def get_account_info(self, oauth_id: str, params: any) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://api.oceanengine.com/open_api/2/agent/advertiser_info/query/"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}}
)
+3 -1
View File
@@ -187,11 +187,13 @@ class DouyinRequest:
url: str,
method: str = 'GET',
options: any = None,
request_count: int = 1,
) -> Any:
options = options or {}
token = await self.get_access_token(oauth_id)
for i in range(1, 2):
for i in range(1, request_count+1):
# 有一些错误是触发频次管理的,需要重试
try:
headers = options.get('headers', {}).copy()
headers['Access-Token'] = token
+1 -1
View File
@@ -8,7 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
#dist
dist-ssr
*.local
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DTCz7Wbv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+11 -4
View File
@@ -367,8 +367,12 @@ export async function generateReplication(params: any): Promise<any> {
return api.post('/hot-opening-replications/tasks', params);
}
// 获取爆款开头复刻任务列表
export async function getReplicationList(page: number,page_size: number): Promise<any[]> {
return api.get(`/hot-opening-replications/tasks?page=${page}&page_size=${page_size}`);
export async function getReplicationList(page: number, page_size: number, keyword?: string): Promise<any[]> {
let url = `/hot-opening-replications/tasks?page=${page}&page_size=${page_size}`;
if (keyword) {
url += `&keyword=${encodeURIComponent(keyword)}`;
}
return api.get(url);
}
// 获取爆款开头复刻任务详情
export async function getReplicationDetail(id: string): Promise<any> {
@@ -497,8 +501,11 @@ export async function createShotReplication(params: any): Promise<any> {
return api.post('/shot-replications/task-sets', params);
}
// 获取镜头复刻任务列表
export async function getShotReplicationList(page: number, page_size: number): Promise<any> {
return api.get(`/shot-replications/task-sets?page=${page}&page_size=${page_size}`);
export async function getShotReplicationList(page: number, page_size: number, keyword?: string): Promise<any> {
const url = keyword
? `/shot-replications/task-sets?page=${page}&page_size=${page_size}&keyword=${encodeURIComponent(keyword)}`
: `/shot-replications/task-sets?page=${page}&page_size=${page_size}`;
return api.get(url);
}
// 获取镜头复刻任务详情
export async function getShotReplicationDetail(taskSetId: string): Promise<any> {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

@@ -1,5 +1,5 @@
import React from 'react';
import { Alert, Input, Space, Tag, Typography } from 'antd';
import { Alert, Input, Radio, Space, Tag, Typography } from 'antd';
import type {
JsonValue,
VideoPromptSchemaFieldConfig,
@@ -54,10 +54,24 @@ function EditableInput({ value, maxLength, onChange }: { value: JsonValue; maxLe
return <Input {...commonProps} />;
}
function renderFieldTag(field: VideoPromptSchemaFieldConfig) {
return field.editable ? <Tag color="processing"></Tag> : <Tag color="default"></Tag>;
function BooleanRadio({ value, onChange }: { value: JsonValue; onChange: (nextValue: JsonValue) => void }) {
const boolValue = value === true || value === 'true';
return (
<Radio.Group
value={boolValue}
onChange={(e) => onChange(e.target.value)}
style={{ display: 'flex', gap: 24 }}
>
<Radio value={true} style={{ fontSize: 14, color: '#475569' }}></Radio>
<Radio value={false} style={{ fontSize: 14, color: '#475569' }}></Radio>
</Radio.Group>
);
}
// function renderFieldTag(field: VideoPromptSchemaFieldConfig) {
// return field.editable ? <Tag color="processing">可编辑</Tag> : <Tag color="default">只读</Tag>;
// }
const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value, schemaConfigSnapshot, onChange }) => {
const safeValue = isRecord(value) ? value : {};
const sections = normalizeSchemaSections(schemaConfigSnapshot);
@@ -93,21 +107,28 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
<div key={section.key} style={{ marginBottom: 18 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text>
{renderFieldTag(section)}
{/* {renderFieldTag(section)} */}
</Space>
<div style={{ borderLeft: '3px solid #6366f1', paddingLeft: 12, marginLeft: 4 }}>
{fields.map((field) => (
<div key={field.key} style={{ marginBottom: 12 }}>
<Space style={{ marginBottom: 4 }}>
<Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text>
{renderFieldTag(field)}
{/* {renderFieldTag(field)} */}
</Space>
{field.editable ? (
<EditableInput
value={sectionValue[field.key]}
maxLength={field.maxLength}
onChange={(nextText) => onChange(setObjectField(safeValue, section.key, field.key, nextText))}
/>
field.type === 'boolean' ? (
<BooleanRadio
value={sectionValue[field.key]}
onChange={(nextValue) => onChange(setObjectField(safeValue, section.key, field.key, nextValue))}
/>
) : (
<EditableInput
value={sectionValue[field.key]}
maxLength={field.maxLength}
onChange={(nextText) => onChange(setObjectField(safeValue, section.key, field.key, nextText))}
/>
)
) : (
<ReadonlyBlock value={sectionValue[field.key]} />
)}
@@ -142,14 +163,21 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
<div key={field.key} style={{ marginBottom: 10 }}>
<Space style={{ marginBottom: 4 }}>
<Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text>
{renderFieldTag(field)}
{/* {renderFieldTag(field)} */}
</Space>
{field.editable ? (
<EditableInput
value={row[field.key]}
maxLength={field.maxLength}
onChange={(nextText) => onChange(setArrayObjectField(safeValue, section.key, index, field.key, nextText))}
/>
field.type === 'boolean' ? (
<BooleanRadio
value={row[field.key]}
onChange={(nextValue) => onChange(setArrayObjectField(safeValue, section.key, index, field.key, nextValue))}
/>
) : (
<EditableInput
value={row[field.key]}
maxLength={field.maxLength}
onChange={(nextText) => onChange(setArrayObjectField(safeValue, section.key, index, field.key, nextText))}
/>
)
) : (
<ReadonlyBlock value={row[field.key]} />
)}
@@ -170,7 +198,7 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
<div key={section.key} style={{ marginBottom: 18 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text>
{renderFieldTag(section)}
{/* {renderFieldTag(section)} */}
</Space>
{section.editable ? (
<EditableInput
@@ -191,13 +219,13 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
return (
<div>
<Alert
{/* <Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="仅可修改后台 Schema 配置允许编辑的字段"
description="视频规格、时间段、流程条目数量、输出规格、质量控制、合规控制等锁定内容由服务端最终校验。"
/>
/> */}
{sections.map((section) => {
if (!(section.key in safeValue)) return null;
if (section.type === 'object') return renderObjectSection(section);
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Modal, Slider, Spin, Typography } from 'antd';
import { PauseOutlined, PlayCircleFilled } from '@ant-design/icons';
import { PauseCircleFilled, PlayCircleFilled } from '@ant-design/icons';
const { Text } = Typography;
@@ -469,16 +469,29 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
width={980}
destroyOnHidden
footer={[
<Button key="cancel" onClick={onCancel} disabled={loading}>
<Button key="cancel" onClick={onCancel} disabled={loading} style={{ borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)', color: '#6366f1' }}>
</Button>,
<Button key="confirm" type="primary" onClick={handleConfirm} loading={loading} disabled={loading || disabledByDuration || !integerDuration}>
<Button key="confirm" type="primary" onClick={handleConfirm} loading={loading} disabled={loading || disabledByDuration || !integerDuration} style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}>
</Button>,
]}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'flex', justifyContent: 'center', background: '#fff' }}>
<style>{`
.ant-slider-handle::after {
height: 80px !important;
width: 10px !important;
border-radius: 10px !important;
top: 95% !important;
transform: translateY(-50%) !important;
}
.ant-slider-handle {
height: 80px !important;
margin-top: -37px !important;
}
`}</style>
<div style={{ display: 'flex', justifyContent: 'center', background: '#fff', borderRadius: 16, overflow: 'hidden' }}>
<video
ref={videoRef}
src={videoUrl}
@@ -497,26 +510,26 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
}}
onTimeUpdate={handleTimeUpdate}
onPause={() => setPlaying(false)}
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', background: '#111', borderRadius: 8 }}
style={{ maxHeight: 320, objectFit: 'contain', background: '#111' }}
/>
</div>
<div
style={{
borderRadius: 24,
background: '#f3f6ff',
padding: '26px 34px 18px',
borderRadius: 20,
background: '#f0f5ff',
padding: '20px 24px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 28, marginBottom: 22 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, marginBottom: 18 }}>
<Button
type="text"
icon={playing ? <PauseOutlined /> : <PlayCircleFilled />}
icon={playing ? <PauseCircleFilled /> : <PlayCircleFilled />}
onClick={handlePlaySelected}
disabled={!integerDuration || disabledByDuration}
style={{ fontSize: 22, color: '#111' }}
style={{ fontSize: 20, color: '#1e293b', padding: 0 }}
/>
<span style={{ fontSize: 26, color: '#111', letterSpacing: 1 }}>
<span style={{ fontSize: 18, color: '#1e293b', fontFamily: 'monospace' }}>
{formatTime(currentTime)} / {formatTime(integerDuration)}
</span>
</div>
@@ -528,8 +541,9 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
gridTemplateColumns: `repeat(${Math.max(frames.length, 1)}, minmax(42px, 1fr))`,
height: 86,
overflow: 'hidden',
borderRadius: 10,
background: '#dbe2ff',
borderRadius: 12,
background: '#fff',
position: 'relative',
}}
>
{frameLoading && frames.length === 0 ? (
@@ -538,23 +552,27 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
<Text style={{ marginLeft: 8, color: '#64748b' }}>...</Text>
</div>
) : frames.length > 0 ? (
frames.map((frame) => (
<button
key={frame.second}
type="button"
onClick={() => handleFrameClick(frame.second)}
title={`${frame.second}s${frame.fallback ? `,实际取帧 ${frame.captureSecond?.toFixed(1)}s` : ''}`}
style={{
minWidth: 0,
height: 86,
border: 'none',
padding: 0,
background: '#eef2ff',
overflow: 'hidden',
cursor: disabledByDuration ? 'not-allowed' : 'pointer',
position: 'relative',
}}
>
frames.map((frame) => {
const isSelected = frame.second >= range[0] && frame.second < range[1];
return (
<button
key={frame.second}
type="button"
onClick={() => handleFrameClick(frame.second)}
title={`${frame.second}s${frame.fallback ? `,实际取帧 ${frame.captureSecond?.toFixed(1)}s` : ''}`}
style={{
minWidth: 0,
height: 86,
borderTop: isSelected ? '5px solid #6366f1' : 'none',
borderBottom: isSelected ? '5px solid #6366f1' : 'none',
padding: 0,
background: 'transparent',
overflow: 'hidden',
cursor: disabledByDuration ? 'not-allowed' : 'pointer',
position: 'relative',
// borderRadius: 4,
}}
>
{frame.status === 'success' && frame.image ? (
<img src={frame.image} alt={`${frame.second}s`} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : frame.status === 'loading' ? (
@@ -562,7 +580,7 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
<Spin size="small" />
</div>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, background: '#eef2ff' }}>
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, background: '#fff' }}>
<span>{frame.second}s</span>
<span style={{ fontSize: 11 }}></span>
</div>
@@ -580,15 +598,17 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
{frame.second}s
</span>
</button>
))
);
})
) : (
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748b' }}>
</div>
)}
</div>
<div style={{ marginTop: 0, padding: '0 8px 0' }}>
<div style={{ position: 'absolute', top: -6, left: 6, right: 0, }}>
<Slider
range
min={0}
@@ -601,38 +621,35 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
disabled={!integerDuration || disabledByDuration}
styles={{
track: {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
height: 6,
background: '#6969dd63',
height: 70,
borderRadius: 3,
margin: '0 ',
},
rail: {
background: '#e2e8f0',
height: 6,
// background: '#0e447e70',
height: 70,
borderRadius: 3,
},
handle: {
width: 18,
height: 18,
width: 20,
height: 70,
marginTop: 0,
// backgroundColor: '#fff',
// border: '3px solid #6366f1',
// boxShadow: '0 2px 8px rgba(99, 102, 241, 0.3)',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
// border: '2px solid #6366f1',
borderRadius: '50%',
},
}}
/>
</div>
</div>
<div style={{ marginTop: 30, textAlign: 'center', color: '#64748b', fontSize: 16 }}>
<div style={{ marginTop: 16, textAlign: 'center', color: '#64748b', fontSize: 14 }}>
{selectedDuration}s
<span style={{ marginLeft: 12, fontSize: 13, color: '#94a3b8' }}>
{minDuration}s {maxDuration}s
</span>
</div>
{localError && (
<div style={{ marginTop: 12, textAlign: 'center', color: '#ef4444', fontSize: 13 }}>
<div style={{ marginTop: 12, padding: '10px 14px', borderRadius: 8, background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.15)', color: '#ef4444', fontSize: 13, textAlign: 'center' }}>
{localError}
</div>
)}
+224 -160
View File
@@ -15,6 +15,10 @@ import {
Modal,
} from 'antd';
import bg1 from '../assets/bg1.png';
import bg2 from '../assets/bg2.png';
import {
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
@@ -809,7 +813,7 @@ const AIChatPage: React.FC = () => {
const handleClosePreview = () => {
setPreviewVisible(false);
setPreviewUrl('');
setPreviewUrl('');
if (videoRef.current) {
videoRef.current.pause();
}
@@ -830,7 +834,16 @@ const AIChatPage: React.FC = () => {
// ==================== 渲染 ====================
return (
<Layout style={{ height: 'calc(100vh - 90px)', overflow: 'auto' }}>
<Layout style={{
margin: '-24px -32px -32px',
borderRadius: 20,
height: 'calc(100vh - 34px)',
background: 'linear-gradient(135deg, #f8fafc 0%, #f0f4ff 50%, #faf5ff 100%)',
backgroundImage: `url(${bg2})`,
backgroundRepeat: 'no-repeat',
backgroundSize: '100% 100%',
backgroundPosition: 'center',
}}>
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
{false && (
<Sider
@@ -839,8 +852,9 @@ const AIChatPage: React.FC = () => {
collapsed={collapsed}
width={220}
style={{
background: '#fff',
borderRight: '1px solid #f0f0f0',
background: 'rgba(255,255,255,0.7)',
backdropFilter: 'blur(20px)',
borderRight: '1px solid rgba(99, 102, 241, 0.08)',
}}
>
<div style={{ padding: 12 }}>
@@ -917,38 +931,46 @@ const AIChatPage: React.FC = () => {
)}
{/* 主内容区 */}
<Layout style={{ flex: 1, background: '#ffffffff' }}>
<Layout style={{ display: 'flex', flex: 1, background: 'transparent' }}>
{/* 头部 - 显示对话标题和模型信息 */}
<Header
style={{
background: '#fff',
// padding: '0 24px',
borderBottom: '1px solid #f0f0f0',
borderRadius: '20px 20px 0 0',
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(20px)',
borderBottom: '1px solid rgba(99, 102, 241, 0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
}}
>
<div>
<Text strong style={{ fontSize: 16 }}>
{'开启创作'}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<RobotOutlined style={{ fontSize: 18, color: '#fff' }} />
</div>
<div>
<Text strong style={{ fontSize: 16, background: 'linear-gradient(90deg, #6366f1, #8b5cf6)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
</Text>
{/* <div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, #6366f1, #8b5cf6)', borderRadius: 1, marginTop: 4 }} /> */}
</div>
</div>
{/* <div style={{ display: 'flex', gap: 16, fontSize: 12, color: '#999' }}>
<span>模型: Gemini 3 Pro</span>
<span>比例: 9:16</span>
<span style={{ color: '#6366f1', fontWeight: 500 }}>消耗 15 积分</span>
</div> */}
</Header>
{/* 消息区域 */}
<Content
style={{
flex: 1,
margin: 0,
padding: 24,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
// padding: 24,
overflow: 'hidden',
minHeight: 0,
}}
>
{/* 空状态 - 没有对话或当前对话没有消息时显示 */}
@@ -982,10 +1004,15 @@ const AIChatPage: React.FC = () => {
<div
ref={scrollContainerRef}
style={{
height: 'calc(100vh - 280px)', // 固定高度,减去头部和输入区域
overflowY: 'auto', // 垂直滚动
flex: 1,
overflowY: 'auto',
paddingBottom: 24,
paddingRight: 8, // 预留滚动条空间
paddingRight: 8,
// backgroundImage: `url(${bg1})`,
// backgroundRepeat: 'no-repeat',
// backgroundSize: 'cover',
// backgroundPosition: 'center',
}}
>
{/* 加载更多按钮 - 在列表顶部 */}
@@ -1015,81 +1042,56 @@ const AIChatPage: React.FC = () => {
<div
key={msg.id}
style={{
width: '100%',
display: 'flex',
justifyContent: 'flex-start', // 所有消息左对齐
marginBottom: 16,
}}
>
<div style={{ display: 'flex', gap: 10, maxWidth: '70%' }}>
<div style={{ width: '100%', display: 'flex', gap: 10 }}>
{/* 头像 */}
<div
style={{
width: 36,
height: 36,
borderRadius: 50,
background: '#e0e0e0',
borderRadius: 12,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
}}
>
<RobotOutlined style={{ color: '#666', fontSize: 16 }} />
<RobotOutlined style={{ color: '#fff', fontSize: 16 }} />
</div>
{/* 消息内容 */}
<div>
<div style={{ flex: 1 }}>
{/* 时间戳和参数信息 */}
<div style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
{/* 引擎标签 */}
<span >
{/* <SettingsOutlined style={{ fontSize: 12 }} /> */}
{msg.engineSnapshot.name}
</span>
{/* 参数标签 */}
<span >
{/* <LayoutGridOutlined style={{ fontSize: 12 }} /> */}
{msg.genType === 'image'
? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}`
: `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`
}
</span>
<span style={{ marginLeft: 8 }}>{msg.creditsCost}</span>
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<span
style={{ marginLeft: 20, color: '#6366f1', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
setAttachmentPopupPosition({
x: rect.left,
y: rect.top - 10
});
setAttachmentPopupMessageId(msg.id);
setAttachmentPopupVisible(true);
}}
>
</span>
)}
</div>
{/* 消息气泡 */}
<div
style={{
background: '#fff',
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(20px)',
borderRadius: '16px 16px 16px 4px',
padding: '12px 16px',
width: '600px',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
width: '70%',
minWidth: 500,
boxSizing: 'border-box',
boxShadow: '0 4px 20px rgba(99, 102, 241, 0.08), 0 1px 3px rgba(0,0,0,0.04)',
position: 'relative',
border: '1px solid rgba(99, 102, 241, 0.06)',
}}
>
<div style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span>{msg.createdAt?.replace('T', ' ').split('.')[0]}</span>
</div>
{/* 删除按钮 - 右上角 */}
<div style={{ position: 'absolute', top: 8, right: 8 ,zIndex: 100}}>
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
<Popconfirm
title="确定要删除吗?"
onConfirm={async () => {
@@ -1115,8 +1117,8 @@ const AIChatPage: React.FC = () => {
height: 28,
borderRadius: 8,
border: 'none',
background: 'rgba(146, 144, 144, 1)',
color: '#ffffffff',
background: 'rgba(99, 102, 241, 0.08)',
color: '#6366f1',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
@@ -1125,35 +1127,36 @@ const AIChatPage: React.FC = () => {
padding: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#fff2f0';
e.currentTarget.style.color = '#ff4d4f';
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
e.currentTarget.style.color = '#ef4444';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(0,0,0,0.05)';
e.currentTarget.style.color = '#999';
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.08)';
e.currentTarget.style.color = '#6366f1';
}}
>
<DeleteOutlined style={{ fontSize: 12 }} />
</button>
</Popconfirm>
</div>
{/* 文本内容 */}
<div
{/* <div
style={{
width: '100%',
position: 'relative',
margin: '8px 0',
padding: '12px 16px',
backgroundColor: '#f8fafc',
borderRadius: 8,
border: '1px solid #e2e8f0',
backgroundColor: 'rgba(99, 102, 241, 0.04)',
borderRadius: 10,
border: '1px solid rgba(99, 102, 241, 0.08)',
fontSize: 13,
color: '#475569',
lineHeight: 1.6,
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)',
boxShadow: '0 1px 3px rgba(99, 102, 241, 0.04)',
}}
onMouseEnter={() => {
setExpandedPrompts(prev => {
@@ -1170,8 +1173,9 @@ const AIChatPage: React.FC = () => {
});
}}
>
{/* 默认显示:一行省略 */}
默认显示:一行省略
<div style={{
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
@@ -1179,9 +1183,10 @@ const AIChatPage: React.FC = () => {
}}>
{msg.originalPrompt}
</div>
{/* 鼠标移入显示:完整内容 */}
鼠标移入显示:完整内容
<div style={{
width: '100%',
maxHeight: 200,
overflowY: 'auto',
display: expandedPrompts.has(msg.id) ? 'block' : 'none',
@@ -1189,17 +1194,17 @@ const AIChatPage: React.FC = () => {
}}>
{msg.originalPrompt}
</div>
</div>
</div> */}
{/* 根据 status 显示不同内容 */}
{/* 生成中 - 显示加载动画 */}
{msg.status === 'generating' && (
<div style={{
height: 200,
marginBottom: 10,
marginTop: 12,
borderRadius: 8,
overflow: 'hidden',
<div style={{
height: 200,
marginBottom: 10,
marginTop: 12,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
background: 'linear-gradient(135deg, #e0e7ff 0%, #f3e8ff 50%, #e0e7ff 100%)',
}}>
@@ -1231,13 +1236,13 @@ const AIChatPage: React.FC = () => {
)}
{/* 生成失败 - 显示失败提示 */}
{msg.status === 'failed' && (
<div style={{
height: 200,
marginBottom: 10,
marginTop: 12,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
<div style={{
height: 200,
marginBottom: 10,
marginTop: 12,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
backgroundColor: '#fafafa',
border: '1px dashed #e2e8f0',
}}>
@@ -1268,14 +1273,14 @@ const AIChatPage: React.FC = () => {
)}
{/* 已完成 - 显示媒体内容 */}
{msg.status === 'completed' && (
<div style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 8, marginBottom: 10, marginTop: 12, width: '100%', }}>
<div style={{ display: 'flex', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 8, marginBottom: 10, marginTop: 12, width: '100%', }}>
<div
onClick={() => {
setPreviewUrl(msg.genType === 'image' ? msg.imageUrl : msg.videoUrl);
setPreviewType(msg.genType === 'image' ? 'image' : 'video');
setPreviewVisible(true);
}}
style={{ cursor: 'pointer', overflow: 'hidden', borderRadius: 8, position: 'relative', height: 200 }}
style={{ width: '50%', cursor: 'pointer', overflow: 'hidden', borderRadius: 8, position: 'relative', height: 200 }}
>
{msg.genType === 'image' ? (
<img
@@ -1317,6 +1322,46 @@ const AIChatPage: React.FC = () => {
</>
)}
</div>
<div style={{ width: '50%', }}>
<p style={{ marginBottom: 20 ,height: 100, overflow: 'auto', padding: 0, margin: 0 }}>
{msg.originalPrompt}
</p>
<div style={{fontSize: 11, color: '#999', textAlign: 'left', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{/* 引擎标签 */}
<span >
{/* <SettingsOutlined style={{ fontSize: 12 }} /> */}
{msg.engineSnapshot.name}
</span>
{/* 参数标签 */}
<span >
{/* <LayoutGridOutlined style={{ fontSize: 12 }} /> */}
{msg.genType === 'image'
? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}`
: `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`
}
</span>
<span style={{ marginLeft: 8 }}>{msg.creditsCost}</span>
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<span
style={{ marginLeft: 20, color: '#6366f1', cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
setAttachmentPopupPosition({
x: rect.left,
y: rect.top - 10
});
setAttachmentPopupMessageId(msg.id);
setAttachmentPopupVisible(true);
}}
>
</span>
)}
</div>
</div>
</div>
)}
@@ -1445,11 +1490,14 @@ const AIChatPage: React.FC = () => {
{/* 输入区域 - 始终显示 */}
<div
style={{
background: '#fff',
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(20px)',
borderRadius: 24,
padding: 16,
boxShadow: '0 4px 20px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.1), 0 2px 8px rgba(0,0,0,0.04)',
transition: 'all 0.3s ease',
border: '1px solid rgba(99, 102, 241, 0.08)',
// margin: '0 24px 24px',
}}
>
{/* 已上传媒体预览 */}
@@ -1499,12 +1547,13 @@ const AIChatPage: React.FC = () => {
{/* 输入框区域 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
backgroundColor: '#ffffff',
backgroundColor: 'rgba(248,250,252,0.6)',
borderRadius: 16,
border: '1px solid #e2e8f0',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
border: '1px solid rgba(99, 102, 241, 0.08)',
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
transition: 'all 0.2s ease',
}}>
{/* 上传按钮 */}
@@ -1518,15 +1567,15 @@ const AIChatPage: React.FC = () => {
style={{
width: 48,
height: 48,
borderRadius: 12,
border: '2px dashed #cbd5e1',
borderRadius: 14,
border: '2px dashed rgba(99, 102, 241, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
flexShrink: 0,
backgroundColor: '#f8fafc',
backgroundColor: 'rgba(255,255,255,0.7)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
@@ -1534,8 +1583,8 @@ const AIChatPage: React.FC = () => {
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#cbd5e1';
e.currentTarget.style.backgroundColor = '#f8fafc';
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.2)';
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.7)';
e.currentTarget.style.transform = 'scale(1)';
}}
>
@@ -1557,6 +1606,7 @@ const AIChatPage: React.FC = () => {
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
flex: 1,
height: '100%',
borderRadius: 12,
border: 'none',
outline: 'none',
@@ -1564,7 +1614,10 @@ const AIChatPage: React.FC = () => {
fontSize: 14,
lineHeight: 1.5,
color: '#1e293b',
resize: 'none',
// resize: 'none',
// boxShadow: 'none',
// padding: '8px 12px',
}}
disabled={loading}
/>
@@ -1582,14 +1635,16 @@ const AIChatPage: React.FC = () => {
width: 48,
height: 48,
borderRadius: 14,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.35)',
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.4)',
transition: 'all 0.2s ease',
border: 'none',
}}
/>
</div>
{/* 底部选择器 - 媒体类型和数量 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, paddingTop: 12, borderTop: '1px solid #f0f0f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, paddingTop: 12, borderTop: '1px solid rgba(99, 102, 241, 0.08)' }}>
<Space size="middle">
{/* 文件类型选择器 */}
{/* <div style={{
@@ -1608,11 +1663,11 @@ const AIChatPage: React.FC = () => {
onChange={(val) => setMediaType(val)}
style={{
width: 100,
outline: 'none',
background: '#f1f5f9',
border: 'none',
borderRadius: 8,
height: 34,
outline: 'none',
border: 'none',
backgroundColor: '#f1f5f9',
borderRadius: 10,
}}
size="middle"
>
@@ -1634,16 +1689,26 @@ const AIChatPage: React.FC = () => {
className="image-settings-trigger"
style={{
minWidth: 200,
padding: '4px 12px',
padding: '6px 14px',
height: 34,
borderRadius: 8,
borderRadius: 10,
border: 'none',
backgroundColor: '#f1f5f9',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s',
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.3)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(99, 102, 241, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.15)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(99, 102, 241, 0.04)';
}}
>
<SettingOutlined style={{ fontSize: 14, color: '#64748b' }} />
@@ -1664,11 +1729,12 @@ const AIChatPage: React.FC = () => {
bottom: 'calc(100% + 8px)',
left: 0,
width: 360,
backgroundColor: '#fff',
borderRadius: 16,
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
backgroundColor: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)',
borderRadius: 14,
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
padding: 16,
border: 'none',
border: '1px solid rgba(99, 102, 241, 0.1)',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
@@ -1679,8 +1745,8 @@ const AIChatPage: React.FC = () => {
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#666666',
fontWeight: 600,
color: '#475569',
}}>
</Text>
@@ -1793,11 +1859,12 @@ const AIChatPage: React.FC = () => {
bottom: 'calc(100% + 8px)',
left: 0,
width: 480,
backgroundColor: '#fff',
borderRadius: 16,
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
backgroundColor: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)',
borderRadius: 14,
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
padding: 16,
border: 'none',
border: '1px solid rgba(99, 102, 241, 0.1)',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
@@ -2074,11 +2141,12 @@ const AIChatPage: React.FC = () => {
bottom: 'calc(100% + 8px)',
left: 0,
width: 400,
backgroundColor: '#fff',
borderRadius: 16,
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
backgroundColor: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)',
borderRadius: 14,
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
padding: 16,
border: 'none',
border: '1px solid rgba(99, 102, 241, 0.1)',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
@@ -2265,22 +2333,12 @@ const AIChatPage: React.FC = () => {
</div>
)}
{/* 预估积分 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Text style={{ fontSize: 12, color: '#94a3b8' }}></Text>
<Text style={{ fontSize: 12, color: '#666666' }}>{getEstimatedCredits()}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', backgroundColor: 'rgba(99, 102, 241, 0.06)', borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.1)' }}>
<Text style={{ fontSize: 13, color: '#6366f1', fontWeight: 500 }}></Text>
<Text style={{ fontSize: 14, color: '#6366f1', fontWeight: 600 }}>{getEstimatedCredits()}</Text>
</div>
</Space>
{/* 底部信息 */}
{/* <div style={{ display: 'flex', gap: 8, fontSize: 12, color: '#999' }}>
<span>GPT Video 2</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 16, height: 16, borderRadius: 4, background: '#f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10 }}>
{mediaType === 'image' ? (selectedRatio === 'auto' ? '智能' : selectedRatio) : videoAspectRatio}
</span>
<span>{countType}</span>
</span>
</div> */}
</div>
</div>
</Content>
@@ -2299,7 +2357,7 @@ const AIChatPage: React.FC = () => {
open={previewVisible}
onCancel={handleClosePreview}
footer={[
<Button key="download" type="primary" onClick={handleDownload}>
<Button key="download" type="primary" onClick={handleDownload} style={{ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 10, border: 'none' }}>
</Button>,
]}
@@ -2309,14 +2367,14 @@ const AIChatPage: React.FC = () => {
<button
onClick={handleClosePreview}
style={{
width: 28,
height: 28,
width: 32,
height: 32,
borderRadius: '50%',
border: 'none',
background: '#ff4d4f',
background: 'rgba(99, 102, 241, 0.1)',
cursor: 'pointer',
fontSize: 14,
color: '#fff',
fontSize: 16,
color: '#6366f1',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
@@ -2324,24 +2382,27 @@ const AIChatPage: React.FC = () => {
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#ff7875';
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
e.currentTarget.style.color = '#ef4444';
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ff4d4f';
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
e.currentTarget.style.color = '#6366f1';
e.currentTarget.style.transform = 'scale(1)';
}}
>
×
</button>
}
style={{ borderRadius: 16 }}
styles={{
body: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
}
},
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
@@ -2388,7 +2449,7 @@ const AIChatPage: React.FC = () => {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}>
}} style={{ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 10, border: 'none' }}>
</Button>,
]}
@@ -2398,14 +2459,14 @@ const AIChatPage: React.FC = () => {
<button
onClick={() => setAttachmentPreviewVisible(false)}
style={{
width: 28,
height: 28,
width: 32,
height: 32,
borderRadius: '50%',
border: 'none',
background: '#ff4d4f',
background: 'rgba(99, 102, 241, 0.1)',
cursor: 'pointer',
fontSize: 14,
color: '#fff',
fontSize: 16,
color: '#6366f1',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
@@ -2413,24 +2474,27 @@ const AIChatPage: React.FC = () => {
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#ff7875';
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
e.currentTarget.style.color = '#ef4444';
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ff4d4f';
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
e.currentTarget.style.color = '#6366f1';
e.currentTarget.style.transform = 'scale(1)';
}}
>
×
</button>
}
style={{ borderRadius: 16 }}
styles={{
body: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
}
},
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
+37 -9
View File
@@ -52,10 +52,13 @@ function InitialInfo() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
// 任务详情数据
const [taskDetail, setTaskDetail] = useState<any>({});
// API 返回的步骤数据
const [apiSteps, setApiSteps] = useState<any[]>([]);
// 当前展开的步骤
const [activeKey, setActiveKey] = useState<string[]>([]);
//
const baseSteps = [
@@ -75,6 +78,21 @@ function InitialInfo() {
engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '',
}));
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
useEffect(() => {
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].status === 'completed' || steps[i].status === 'failed') {
setActiveKey([String(steps[i].childId)]);
return;
}
}
setActiveKey([]);
}, [apiSteps]);
// console.log('steps', steps);
@@ -82,8 +100,8 @@ function InitialInfo() {
// 获取列表数据的函数
const fetchList = (page: number, size: number) => {
getReplicationList(page, size).then((res: any) => {
const fetchList = (page: number, size: number, keyword?: string) => {
getReplicationList(page, size, keyword).then((res: any) => {
if (res.items) {
setTableData(res.items);
}
@@ -98,7 +116,13 @@ function InitialInfo() {
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
fetchList(page, size);
fetchList(page, size, searchKeyword);
};
// 搜索处理
const handleSearch = () => {
setCurrentPage(1);
fetchList(1, pageSize, searchKeyword);
};
// 获取复刻列表数据
@@ -360,7 +384,7 @@ function InitialInfo() {
}
const agincreatevideo = () => {
let params = {
engine_id: steps[2].engineId,
engine_id: steps[3].engineId,
}
@@ -546,13 +570,14 @@ function InitialInfo() {
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px 16px' }}>
<Collapse
defaultActiveKey={['']}
activeKey={activeKey}
onChange={setActiveKey}
ghost
bordered={false}
style={{ background: 'transparent' }}
expandIconPlacement="end"
items={steps.map((step) => ({
key: String(step.id),
key: String(step.childId),
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -1072,7 +1097,7 @@ function InitialInfo() {
<>
<div style={{ padding: '12px 14px', background: 'rgba(255,255,255,0.6)', borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.08)', marginBottom: 4 }}>
<Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.8 }}>
Schema
/
</Text>
</div>
<Space style={{ marginTop: 16, gap: 12, width: '100%' }}>
@@ -1083,7 +1108,7 @@ function InitialInfo() {
style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }}
disabled={step.status !== 'completed' || !taskDetail?.videoGeneration?.promptSchema || !taskDetail?.videoGeneration?.schemaConfigSnapshot}
>
/
</Button>
<Button
onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }}
@@ -1211,9 +1236,12 @@ function InitialInfo() {
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Input
placeholder="搜索产品名称"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 200, borderRadius: 8, marginRight: 8, border: '1px solid rgba(99, 102, 241, 0.15)', background: 'rgba(255,255,255,0.8)' }}
/>
<Button type="primary" style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}>
</Button>
</div>
+18 -6
View File
@@ -34,6 +34,7 @@ const GenerateConver: React.FC = () => {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
// 卡片列表专用状态
const [cardData, setCardData] = useState<any[]>([]);
@@ -114,8 +115,8 @@ const GenerateConver: React.FC = () => {
};
// 获取列表数据的函数
const fetchList = (page: number, size: number, isPolling = false) => {
getReplicationList(page, size).then((res: any) => {
const fetchList = (page: number, size: number, isPolling = false, keyword?: string) => {
getReplicationList(page, size, keyword).then((res: any) => {
if (res.items) {
if (isPolling) {
// 轮询时:增量更新,只更新状态发生变化的项目
@@ -139,7 +140,7 @@ const GenerateConver: React.FC = () => {
// 如果有 processing 状态且轮询未启动,启动轮询
if (hasProcessing && !tablePollingTimer.current) {
tablePollingTimer.current = setInterval(() => {
fetchList(currentPage, pageSize, true);
fetchList(currentPage, pageSize, true, searchKeyword);
}, 5000);
}
// 如果没有 processing 状态且轮询正在运行,停止轮询
@@ -177,7 +178,13 @@ const GenerateConver: React.FC = () => {
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
fetchList(page, size);
fetchList(page, size, false, searchKeyword);
};
// 搜索处理
const handleSearch = () => {
setCurrentPage(1);
fetchList(1, pageSize, false, searchKeyword);
};
// 文件上传前校验
@@ -205,7 +212,7 @@ const GenerateConver: React.FC = () => {
resolve(false);
return;
}
if (video.duration > 15) {
if (video.duration >= 16) {
message.error('视频时长不能超过15秒');
URL.revokeObjectURL(video.src);
resolve(false);
@@ -489,13 +496,14 @@ const GenerateConver: React.FC = () => {
key={item.id}
style={{
background: 'rgba(255,255,255,0.9)',
border: '1px solid rgba(99, 102, 241, 0.6)',
backdropFilter: 'blur(10px)',
borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.06)',
cursor: 'pointer',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
border: '1px solid rgba(99, 102, 241, 0.06)',
// border: '1px solid rgba(99, 102, 241, 0.06)',
}}
onClick={() => navigate(`/initial/${item.id}/initialinfo`)}
onMouseEnter={(e) => {
@@ -1146,6 +1154,9 @@ const GenerateConver: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'flex-end', margin: '16px 24px', flexShrink: 0 }}>
<Input
placeholder="搜索产品名称"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
style={{
width: 220,
borderRadius: 10,
@@ -1156,6 +1167,7 @@ const GenerateConver: React.FC = () => {
/>
<Button
type="primary"
onClick={handleSearch}
style={{
borderRadius: 10,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
+1
View File
@@ -730,6 +730,7 @@ function RemoveInfo() {
}
placement="top"
trigger="hover"
overlayInnerStyle={{ backgroundColor: '#fff', border: '1px solid rgba(99, 102, 241, 0.1)', borderRadius: 12, boxShadow: '0 8px 24px rgba(99, 102, 241, 0.12)' }}
>
<span style={{ marginLeft: 8, fontSize: 13, opacity: 0.9 }}>(AI )</span>
</Tooltip>
+31 -9
View File
@@ -17,6 +17,7 @@ export default function VideoFrameExtractor() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -81,8 +82,21 @@ export default function VideoFrameExtractor() {
idempotency_key: `shot_${Date.now()}`,
};
await createShotReplication(params);
message.success('任务创建成功');
createShotReplication(params).then((res) => {
// message.success('任务创建成功');
getShotReplicationList(1, 20, searchKeyword).then((res) => {
// console.log('获取列表成功', res.items[0].id);
message.loading('创建中...', 3);
setTimeout(() => {
navigate(`/removelens/${res.items[0].id}/removeinfo`);
}, 3000);
// setTableData(res.items || []);
})
})
cleanupResources();
} catch (err) {
message.error('任务创建失败,请重试');
@@ -91,9 +105,9 @@ export default function VideoFrameExtractor() {
}
};
const fetchList = async (page: number, size: number) => {
const fetchList = async (page: number, size: number, keyword?: string) => {
try {
const res = await getShotReplicationList(page, size);
const res = await getShotReplicationList(page, size, keyword);
setTableData(res.items || []);
setTotal(res.total || 0);
setCurrentPage(page);
@@ -104,12 +118,16 @@ export default function VideoFrameExtractor() {
};
const handlePageChange = (page: number, size: number) => {
fetchList(page, size);
fetchList(page, size, searchKeyword);
};
const handleSearch = () => {
fetchList(1, 10, searchKeyword);
};
const handleOpenModal = () => {
setIsModalOpen(true);
fetchList(1, 10);
fetchList(1, 10, searchKeyword);
};
return (
@@ -127,8 +145,8 @@ export default function VideoFrameExtractor() {
<div style={{ maxWidth: 1200, margin: '50px auto', position: 'relative', zIndex: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16,marginBottom: 50 }}>
<div style={{ marginBottom: 50, height: 160, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: 40, height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} />
<div>
<h1 style={{
@@ -440,13 +458,16 @@ export default function VideoFrameExtractor() {
width={800}
footer={null}
styles={{
body: { background: 'linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)' },
body: { background: 'linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)' },
header: { background: '#fff', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', padding: '20px 24px' },
}}
>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16, gap: 8 }}>
<Input
placeholder="搜索产品名称"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
style={{
width: 200,
borderRadius: 10,
@@ -456,6 +477,7 @@ export default function VideoFrameExtractor() {
/>
<Button
type="primary"
onClick={handleSearch}
style={{
borderRadius: 10,
height: 40,
+34 -10
View File
@@ -52,10 +52,13 @@ function InitialInfo() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [searchKeyword, setSearchKeyword] = useState('');
// 任务详情数据
const [taskDetail, setTaskDetail] = useState<any>({});
// API 返回的步骤数据
const [apiSteps, setApiSteps] = useState<any[]>([]);
// 当前展开的步骤
const [activeKey, setActiveKey] = useState<string[]>([]);
//
const baseSteps = [
@@ -75,6 +78,17 @@ function InitialInfo() {
engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '',
}));
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
useEffect(() => {
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].status === 'completed' || steps[i].status === 'failed') {
setActiveKey([String(steps[i].childId)]);
return;
}
}
setActiveKey([]);
}, [apiSteps]);
@@ -82,8 +96,8 @@ function InitialInfo() {
// 获取列表数据的函数
const fetchList = (page: number, size: number) => {
getShotReplicationList(page, size).then((res: any) => {
const fetchList = (page: number, size: number, keyword?: string) => {
getShotReplicationList(page, size, keyword).then((res: any) => {
if (res.items) {
setTableData(res.items);
}
@@ -98,12 +112,17 @@ function InitialInfo() {
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
fetchList(page, size);
fetchList(page, size, searchKeyword);
};
// 搜索处理
const handleSearch = () => {
fetchList(1, 20, searchKeyword);
};
// 获取复刻列表数据
useEffect(() => {
fetchList(currentPage, pageSize);
fetchList(currentPage, pageSize, searchKeyword);
}, []);
// 获取引擎列表
@@ -371,7 +390,7 @@ function InitialInfo() {
}
const agincreatevideo = () => {
let params = {
engine_id: steps[2].engineId,
engine_id: steps[3].engineId,
}
@@ -422,7 +441,7 @@ function InitialInfo() {
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
onClick={() => navigate('/removelens')}
style={{
color: '#64748b',
borderRadius: 10,
@@ -551,13 +570,14 @@ function InitialInfo() {
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px 16px' }}>
<Collapse
defaultActiveKey={['']}
activeKey={activeKey}
onChange={setActiveKey}
ghost
bordered={false}
style={{ background: 'transparent' }}
expandIconPlacement="end"
items={steps.map((step) => ({
key: String(step.id),
key: String(step.childId),
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -1075,7 +1095,7 @@ function InitialInfo() {
<>
<div style={{ padding: '12px 14px', background: 'rgba(255,255,255,0.6)', borderRadius: 10, border: '1px solid rgba(99, 102, 241, 0.08)', marginBottom: 4 }}>
<Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.8 }}>
Schema
/
</Text>
</div>
<Space style={{ marginTop: 16, gap: 12, width: '100%' }}>
@@ -1086,7 +1106,7 @@ function InitialInfo() {
style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }}
disabled={step.status !== 'completed' || !taskDetail?.videoGeneration?.promptSchema || !taskDetail?.videoGeneration?.schemaConfigSnapshot}
>
/
</Button>
<Button onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
@@ -1199,6 +1219,9 @@ function InitialInfo() {
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16, gap: 8 }}>
<Input
placeholder="搜索产品名称"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleSearch}
style={{
width: 200,
borderRadius: 10,
@@ -1208,6 +1231,7 @@ function InitialInfo() {
/>
<Button
type="primary"
onClick={handleSearch}
style={{
borderRadius: 10,
height: 40,