Merge branch 'main' of gitee.com:wg123/video-gen
@@ -8,7 +8,7 @@ __pycache__/
|
||||
.claude/
|
||||
.vscode/
|
||||
.trae/
|
||||
video-gen-app/dist/
|
||||
# video-gen-app/dist/
|
||||
video-gen-api/dist/
|
||||
|
||||
# 使用通配符
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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="缩略图"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 {}}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
#dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 4.9 MiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -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>
|
||||
@@ -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> {
|
||||
|
||||
|
After Width: | Height: | Size: 4.9 MiB |
|
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>
|
||||
)}
|
||||
|
||||
@@ -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%' }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%)',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||