解决冲突
This commit is contained in:
+548
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DveJ8Oia.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DfKYC1kL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -20,6 +20,8 @@ interface VideoEngine {
|
||||
maxDuration: number;
|
||||
maxImageCount: number;
|
||||
maxVideoCount: number;
|
||||
supportsFirstLastFrame: boolean;
|
||||
supportsUniversalReference: boolean;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
}
|
||||
@@ -72,6 +74,8 @@ const AdminVideoEngines: React.FC = () => {
|
||||
max_duration: values.maxDuration ?? 15,
|
||||
max_image_count: values.maxImageCount ?? 2,
|
||||
max_video_count: values.maxVideoCount ?? 0,
|
||||
supports_first_last_frame: values.supportsFirstLastFrame ?? false,
|
||||
supports_universal_reference: values.supportsUniversalReference ?? true,
|
||||
is_active: values.isActive ?? true,
|
||||
priority: values.priority ?? 0,
|
||||
};
|
||||
@@ -112,6 +116,8 @@ const AdminVideoEngines: React.FC = () => {
|
||||
maxDuration: 15,
|
||||
maxImageCount: 2,
|
||||
maxVideoCount: 0,
|
||||
supportsFirstLastFrame: false,
|
||||
supportsUniversalReference: true,
|
||||
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
supportedResolutions: ['480p', '720p', '1080p'],
|
||||
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
@@ -159,6 +165,14 @@ const AdminVideoEngines: React.FC = () => {
|
||||
title: '最大视频', dataIndex: 'maxVideoCount', width: 100,
|
||||
render: (v: number) => <Tag color="cyan">{v} 个</Tag>,
|
||||
},
|
||||
{
|
||||
title: '首尾帧', dataIndex: 'supportsFirstLastFrame', width: 90,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
|
||||
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
@@ -196,7 +210,7 @@ const AdminVideoEngines: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -263,7 +277,18 @@ const AdminVideoEngines: React.FC = () => {
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级">
|
||||
<Form.Item name="supportsFirstLastFrame" label="首尾帧模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
||||
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportsUniversalReference" label="全能参考模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
||||
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
|
||||
<Select size="large" options={[
|
||||
{ value: 0, label: '0 (默认)' },
|
||||
{ value: 1, label: '1' },
|
||||
@@ -273,9 +298,6 @@ const AdminVideoEngines: React.FC = () => {
|
||||
{ value: 10, label: '10 (最高)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"""d4e5f6a7b8c9 - 视频引擎增加首尾帧和全能参考支持字段
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: 6idufv2q1c
|
||||
Create Date: 2026-07-02 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-02 的数据库迁移内容:
|
||||
1. 视频引擎表增加 supports_first_last_frame 字段(是否支持首尾帧模式)
|
||||
2. 视频引擎表增加 supports_universal_reference 字段(是否支持全能参考模式)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'd4e5f6a7b8c9'
|
||||
down_revision: Union[str, None] = 'e2618c38eae5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'video_engines',
|
||||
sa.Column(
|
||||
'supports_first_last_frame',
|
||||
sa.Boolean(),
|
||||
server_default=sa.text('false'),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'video_engines',
|
||||
sa.Column(
|
||||
'supports_universal_reference',
|
||||
sa.Boolean(),
|
||||
server_default=sa.text('true'),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('video_engines', 'supports_universal_reference')
|
||||
op.drop_column('video_engines', 'supports_first_last_frame')
|
||||
@@ -9,6 +9,8 @@ from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.user import User
|
||||
from app.schemas.generation_ai import (
|
||||
GenerationAIEngineOptionsOut,
|
||||
GenerationAIHistoryBatchDeleteOut,
|
||||
GenerationAIHistoryBatchDeleteRequest,
|
||||
GenerationAIHistoryDayItemsOut,
|
||||
GenerationAIHistoryGroupedOut,
|
||||
GenerationAIRetryOut,
|
||||
@@ -31,6 +33,7 @@ from app.services.generation_billing_service import (
|
||||
charge_generation_media_by_params,
|
||||
get_next_credit_attempt_no,
|
||||
)
|
||||
from app.services.generation_history_delete_service import batch_delete_generation_history_items
|
||||
from app.services.generation_log_service import log_task_event
|
||||
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
||||
@@ -87,6 +90,8 @@ router = APIRouter(
|
||||
"supported_durations": [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
"max_duration": 15,
|
||||
"priority": 10,
|
||||
"supports_first_last_frame": False,
|
||||
"supports_universal_reference": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -332,6 +337,53 @@ async def list_history_grouped_days(
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/history/batch",
|
||||
response_model=GenerationAIHistoryBatchDeleteOut,
|
||||
summary="批量删除素材云历史记录",
|
||||
description=(
|
||||
"按 history_source 批量软删除素材云历史记录,单次最多30条。"
|
||||
"generation_record 和 chat_task 入参 ids 为对应记录ID,且只有生成完成后才能删除;"
|
||||
"hot_opening_replicate 入参 ids 为 module_project_id;"
|
||||
"shot_replicate 入参 ids 为 shot_segment_id。"
|
||||
"爆款开头复刻和拆镜复刻会联动软删 ModuleGenerationProject、ModuleGenerationStep、关联 ChatGenerationTask 和 generated_resources;"
|
||||
"拆镜复刻还会联动软删 ShotReplicateSegment。"
|
||||
"如果存在生成中、轮询中、下载中等任务,接口直接拦截,不做失败标记、不退款。"
|
||||
),
|
||||
responses={
|
||||
200: {
|
||||
"description": "批量软删除成功",
|
||||
},
|
||||
400: {
|
||||
"description": "参数错误,例如 history_source 不支持、ids 为空、超过30条或重复",
|
||||
},
|
||||
401: {
|
||||
"description": "未登录或 Token 无效",
|
||||
},
|
||||
404: {
|
||||
"description": "部分 ID 不存在、不属于当前用户或已删除",
|
||||
},
|
||||
409: {
|
||||
"description": "存在未完成或生成中的记录,当前不能删除",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def batch_delete_history_items(
|
||||
req: GenerationAIHistoryBatchDeleteRequest = Body(
|
||||
...,
|
||||
description="素材云历史批量删除参数。不同 history_source 对应不同 ID 语义,详见字段说明",
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await batch_delete_generation_history_items(
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
history_source=req.history_source,
|
||||
ids=req.ids,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/history/{generated_date}",
|
||||
response_model=GenerationAIHistoryDayItemsOut,
|
||||
|
||||
@@ -51,5 +51,7 @@ async def list_active_engines(
|
||||
"supported_durations": durations,
|
||||
"max_image_count": e.max_image_count,
|
||||
"max_video_count": e.max_video_count,
|
||||
"supports_first_last_frame": e.supports_first_last_frame,
|
||||
"supports_universal_reference": e.supports_universal_reference,
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
@@ -118,3 +118,6 @@ def is_generation_history_module_source(source: GenerationHistorySourceEnum) ->
|
||||
"""判断当前来源是否需要回填模块项目信息。"""
|
||||
|
||||
return source in GENERATION_HISTORY_MODULE_SOURCES
|
||||
|
||||
|
||||
MAX_BATCH_DELETE_COUNT = 30
|
||||
|
||||
@@ -19,6 +19,8 @@ class VideoEngine(Base, TimestampMixin):
|
||||
max_duration: Mapped[int] = mapped_column(Integer, default=15)
|
||||
max_image_count: Mapped[int] = mapped_column(Integer, default=2)
|
||||
max_video_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
supports_first_last_frame: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
supports_universal_reference: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
|
||||
query_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
from app.enums.generation_history import normalize_generation_history_source
|
||||
|
||||
|
||||
class GenerationAIReference(BaseModel):
|
||||
@@ -192,6 +193,8 @@ class GenerationAIVideoEngineOptionOut(BaseModel):
|
||||
priority: int = Field(0, description="引擎优先级,数值越大越优先")
|
||||
max_image_count: int | None = Field(None, description="最大图片数量")
|
||||
max_video_count: int | None = Field(None, description="最大视频数量")
|
||||
supports_first_last_frame: bool = Field(False, description="是否支持首帧和最后一帧")
|
||||
supports_universal_reference: bool = Field(False, description="是否支持通用参考")
|
||||
|
||||
|
||||
class GenerationAIEngineGroupOut(BaseModel):
|
||||
@@ -245,6 +248,8 @@ class GenerationAIEngineOptionsOut(BaseModel):
|
||||
"priority": 10,
|
||||
"max_image_count": 2,
|
||||
"max_video_count": 0,
|
||||
"supports_first_last_frame": False,
|
||||
"supports_universal_reference": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -492,6 +497,95 @@ class GenerationAITaskDeleteOut(BaseModel):
|
||||
freed_size_bytes: int = Field(0, description="本次软删联动释放的有效资源空间字节数")
|
||||
|
||||
|
||||
class GenerationAIHistoryBatchDeleteRequest(BaseModel):
|
||||
"""素材云历史批量删除请求体。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"history_source": "shot_replicate",
|
||||
"ids": ["shot_segment_id_1", "shot_segment_id_2"],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
history_source: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"素材云历史来源。"
|
||||
"generation_record=项目生成,chat_task=AI创作,"
|
||||
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻。"
|
||||
"注意:项目生成/AI创作传记录ID;爆款开头复刻传 module_project_id;拆镜复刻传 shot_segment_id。"
|
||||
),
|
||||
examples=["shot_replicate"],
|
||||
)
|
||||
ids: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=30,
|
||||
description="需要删除的ID数组,最多30个;不允许重复或空字符串",
|
||||
examples=[["shot_segment_id_1", "shot_segment_id_2"]],
|
||||
)
|
||||
|
||||
@field_validator("history_source")
|
||||
@classmethod
|
||||
def validate_history_source(cls, value: str) -> str:
|
||||
try:
|
||||
return normalize_generation_history_source(value).value
|
||||
except ValueError as exc:
|
||||
raise ValueError("history_source 不支持") from exc
|
||||
|
||||
@field_validator("ids")
|
||||
@classmethod
|
||||
def validate_ids(cls, values: list[str]) -> list[str]:
|
||||
normalized = [str(item).strip() for item in values if str(item or "").strip()]
|
||||
if not normalized:
|
||||
raise ValueError("ids 不能为空")
|
||||
if len(normalized) > 30:
|
||||
raise ValueError("单次最多删除30条记录")
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise ValueError("ids 不允许重复")
|
||||
return normalized
|
||||
|
||||
|
||||
class GenerationAIHistoryBatchDeleteOut(BaseModel):
|
||||
"""素材云历史批量删除响应体。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"message": "删除成功",
|
||||
"history_source": "shot_replicate",
|
||||
"history_source_label": "拆镜复刻",
|
||||
"requested_count": 2,
|
||||
"deleted_count": 2,
|
||||
"requested_ids": ["shot_segment_id_1", "shot_segment_id_2"],
|
||||
"deleted_ids": ["shot_segment_id_1", "shot_segment_id_2"],
|
||||
"generation_record_ids": [],
|
||||
"chat_task_ids": ["chat_task_id_1", "chat_task_id_2"],
|
||||
"module_project_ids": ["module_project_id_1", "module_project_id_2"],
|
||||
"shot_segment_ids": ["shot_segment_id_1", "shot_segment_id_2"],
|
||||
"deleted": True,
|
||||
"freed_size_bytes": 123456,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
message: str = Field(..., description="操作结果提示信息")
|
||||
history_source: str = Field(..., description="素材云历史来源")
|
||||
history_source_label: str | None = Field(None, description="素材云历史来源中文名称")
|
||||
requested_count: int = Field(..., description="请求删除数量")
|
||||
deleted_count: int = Field(..., description="实际删除数量")
|
||||
requested_ids: list[str] = Field(default_factory=list, description="请求删除的原始ID列表")
|
||||
deleted_ids: list[str] = Field(default_factory=list, description="已删除的原始ID列表")
|
||||
generation_record_ids: list[str] = Field(default_factory=list, description="联动软删除的 GenerationRecord ID")
|
||||
chat_task_ids: list[str] = Field(default_factory=list, description="联动软删除的 ChatGenerationTask ID")
|
||||
module_project_ids: list[str] = Field(default_factory=list, description="联动软删除的 ModuleGenerationProject ID")
|
||||
shot_segment_ids: list[str] = Field(default_factory=list, description="联动软删除的 ShotReplicateSegment ID")
|
||||
deleted: bool = Field(..., description="是否已完成软删除")
|
||||
freed_size_bytes: int = Field(0, description="本次软删联动释放的有效资源空间字节数")
|
||||
|
||||
|
||||
class GenerationAIRetryOut(BaseModel):
|
||||
"""AI生成任务重试响应体。"""
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ class VideoEngineCreate(BaseModel):
|
||||
max_duration: int = Field(default=15)
|
||||
max_image_count: int = Field(default=2)
|
||||
max_video_count: int = Field(default=0)
|
||||
supports_first_last_frame: bool = Field(default=False, description="是否支持首尾帧模式")
|
||||
supports_universal_reference: bool = Field(default=True, description="是否支持全能参考模式")
|
||||
generate_url: str = Field(default="", max_length=512)
|
||||
query_url: str = Field(default="", max_length=512)
|
||||
is_active: bool = True
|
||||
@@ -37,6 +39,8 @@ class VideoEnginePublic(BaseModel):
|
||||
supported_durations: list[int] = []
|
||||
max_image_count: int = 2
|
||||
max_video_count: int = 0
|
||||
supports_first_last_frame: bool = False
|
||||
supports_universal_reference: bool = True
|
||||
|
||||
|
||||
class VideoEngineListResponse(BaseModel):
|
||||
|
||||
@@ -203,6 +203,8 @@ async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEng
|
||||
priority=engine.priority or 0,
|
||||
max_image_count=engine.max_image_count,
|
||||
max_video_count=engine.max_video_count,
|
||||
supports_first_last_frame=engine.supports_first_last_frame,
|
||||
supports_universal_reference=engine.supports_universal_reference,
|
||||
)
|
||||
for engine in video_result.scalars().all()
|
||||
]
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.common import ModuleEventTypeEnum
|
||||
from app.enums.generation_history import (
|
||||
GenerationHistorySourceEnum,
|
||||
get_generation_history_source_label,
|
||||
normalize_generation_history_source,
|
||||
MAX_BATCH_DELETE_COUNT,
|
||||
)
|
||||
from app.enums.generation_task import ChatGenerationTaskStatus, GenerationMode
|
||||
from app.enums.shot_replicate import ShotSegmentAnalysisStatusEnum, ShotSegmentReplicateStatusEnum, ShotSplitStatusEnum
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.generation_record import GenerationRecord
|
||||
from app.models.module_generation_project import ModuleGenerationProject
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.models.user import User
|
||||
from app.schemas.generation_ai import GenerationAIHistoryBatchDeleteOut
|
||||
from app.services.module_generation_flow_base_service import is_active_chat_generation_task
|
||||
from app.services.module_generation_log_service import log_module_event_file
|
||||
# from app.services.operation_log import log_operation
|
||||
from app.services.resource_accounting_service import (
|
||||
SOURCE_MODEL_CHAT_TASK,
|
||||
SOURCE_MODEL_GENERATION_RECORD,
|
||||
SOURCE_MODEL_SHOT_SEGMENT,
|
||||
soft_delete_generation_record_resources,
|
||||
soft_delete_resources_by_source,
|
||||
)
|
||||
|
||||
|
||||
COMPLETED_STATUS = ChatGenerationTaskStatus.COMPLETED.value
|
||||
|
||||
_ACTIVE_SPLIT_STATUSES = {
|
||||
ShotSplitStatusEnum.PENDING.value,
|
||||
ShotSplitStatusEnum.PROCESSING.value,
|
||||
ShotSplitStatusEnum.RETRY_WAITING.value,
|
||||
}
|
||||
_ACTIVE_SEGMENT_ANALYSIS_STATUSES = {
|
||||
ShotSegmentAnalysisStatusEnum.PENDING.value,
|
||||
ShotSegmentAnalysisStatusEnum.PROCESSING.value,
|
||||
}
|
||||
_ACTIVE_SEGMENT_REPLICATE_STATUSES = {
|
||||
ShotSegmentReplicateStatusEnum.PROCESSING.value,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_ids(ids: Sequence[str] | Iterable[str]) -> list[str]:
|
||||
normalized = [str(item).strip() for item in ids if str(item or "").strip()]
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=400, detail="ids 不能为空")
|
||||
if len(normalized) > MAX_BATCH_DELETE_COUNT:
|
||||
raise HTTPException(status_code=400, detail=f"单次最多删除 {MAX_BATCH_DELETE_COUNT} 条记录")
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise HTTPException(status_code=400, detail="ids 不允许重复")
|
||||
return normalized
|
||||
|
||||
|
||||
def _missing_ids(request_ids: list[str], actual_ids: Iterable[str]) -> list[str]:
|
||||
actual_set = {str(item) for item in actual_ids if item}
|
||||
return [item for item in request_ids if item not in actual_set]
|
||||
|
||||
|
||||
def _raise_missing_if_any(*, ids: list[str], found_ids: Iterable[str], message: str) -> None:
|
||||
missing = _missing_ids(ids, found_ids)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"message": message,
|
||||
"missing_ids": missing,
|
||||
"missing_count": len(missing),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _raise_invalid_if_any(*, invalid_ids: list[str], message: str, status_code: int = 409) -> None:
|
||||
if invalid_ids:
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail={
|
||||
"message": message,
|
||||
"invalid_ids": invalid_ids,
|
||||
"invalid_count": len(invalid_ids),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _task_id_list(steps: list[ModuleGenerationStep]) -> list[str]:
|
||||
return list(dict.fromkeys(step.chat_task_id for step in steps if step.chat_task_id))
|
||||
|
||||
|
||||
async def _load_chat_tasks_for_steps(
|
||||
db: AsyncSession,
|
||||
steps: list[ModuleGenerationStep],
|
||||
) -> dict[str, ChatGenerationTask]:
|
||||
task_ids = _task_id_list(steps)
|
||||
if not task_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
ChatGenerationTask.id.in_(task_ids),
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
return {task.id: task for task in result.scalars().all()}
|
||||
|
||||
|
||||
def _assert_no_active_chat_tasks(tasks: Iterable[ChatGenerationTask]) -> None:
|
||||
active_task_ids = [task.id for task in tasks if is_active_chat_generation_task(task)]
|
||||
if active_task_ids:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "当前存在生成中任务,请等待生成完成或失败后再操作",
|
||||
"active_count": len(active_task_ids),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# async def _log_history_batch_delete(
|
||||
# db: AsyncSession,
|
||||
# *,
|
||||
# current_user: User,
|
||||
# result: GenerationAIHistoryBatchDeleteOut,
|
||||
# ) -> None:
|
||||
# detail = {
|
||||
# "history_source": result.history_source,
|
||||
# "history_source_label": result.history_source_label,
|
||||
# "requested_count": result.requested_count,
|
||||
# "deleted_count": result.deleted_count,
|
||||
# "requested_ids": result.requested_ids,
|
||||
# "deleted_ids": result.deleted_ids,
|
||||
# "generation_record_ids": result.generation_record_ids,
|
||||
# "chat_task_ids": result.chat_task_ids,
|
||||
# "module_project_ids": result.module_project_ids,
|
||||
# "shot_segment_ids": result.shot_segment_ids,
|
||||
# "freed_size_bytes": result.freed_size_bytes,
|
||||
# }
|
||||
# await log_operation(
|
||||
# db,
|
||||
# current_user.id,
|
||||
# current_user.username,
|
||||
# f"批量删除素材云历史-{result.history_source_label or result.history_source}",
|
||||
# "DELETE",
|
||||
# "/generation-ai/history/batch",
|
||||
# detail=json.dumps(detail, ensure_ascii=False, default=str),
|
||||
# )
|
||||
|
||||
|
||||
def _build_out(
|
||||
*,
|
||||
source: GenerationHistorySourceEnum,
|
||||
requested_ids: list[str],
|
||||
deleted_ids: list[str],
|
||||
generation_record_ids: list[str] | None = None,
|
||||
chat_task_ids: list[str] | None = None,
|
||||
module_project_ids: list[str] | None = None,
|
||||
shot_segment_ids: list[str] | None = None,
|
||||
freed_size_bytes: int = 0,
|
||||
) -> GenerationAIHistoryBatchDeleteOut:
|
||||
return GenerationAIHistoryBatchDeleteOut(
|
||||
message="删除成功",
|
||||
history_source=source.value,
|
||||
history_source_label=get_generation_history_source_label(source),
|
||||
requested_count=len(requested_ids),
|
||||
deleted_count=len(deleted_ids),
|
||||
requested_ids=requested_ids,
|
||||
deleted_ids=deleted_ids,
|
||||
generation_record_ids=generation_record_ids or [],
|
||||
chat_task_ids=chat_task_ids or [],
|
||||
module_project_ids=module_project_ids or [],
|
||||
shot_segment_ids=shot_segment_ids or [],
|
||||
deleted=True,
|
||||
freed_size_bytes=int(freed_size_bytes or 0),
|
||||
)
|
||||
|
||||
|
||||
async def _delete_generation_records(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
source: GenerationHistorySourceEnum,
|
||||
ids: list[str],
|
||||
deleted_at: datetime,
|
||||
) -> GenerationAIHistoryBatchDeleteOut:
|
||||
result = await db.execute(
|
||||
select(GenerationRecord)
|
||||
.where(
|
||||
GenerationRecord.id.in_(ids),
|
||||
GenerationRecord.user_id == current_user.id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
records = list(result.scalars().all())
|
||||
_raise_missing_if_any(ids=ids, found_ids=[record.id for record in records], message="项目生成记录不存在或已删除")
|
||||
|
||||
invalid_ids = [
|
||||
record.id
|
||||
for record in records
|
||||
if record.status != COMPLETED_STATUS or record.generated_at is None
|
||||
]
|
||||
_raise_invalid_if_any(invalid_ids=invalid_ids, message="项目生成记录只有生成完成后才能删除")
|
||||
|
||||
freed_size = await soft_delete_generation_record_resources(db, [record.id for record in records], deleted_at=deleted_at)
|
||||
for record in records:
|
||||
record.deleted_at = deleted_at
|
||||
|
||||
return _build_out(
|
||||
source=source,
|
||||
requested_ids=ids,
|
||||
deleted_ids=ids,
|
||||
generation_record_ids=ids,
|
||||
freed_size_bytes=freed_size,
|
||||
)
|
||||
|
||||
|
||||
async def _delete_chat_tasks(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
source: GenerationHistorySourceEnum,
|
||||
ids: list[str],
|
||||
deleted_at: datetime,
|
||||
) -> GenerationAIHistoryBatchDeleteOut:
|
||||
result = await db.execute(
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
ChatGenerationTask.id.in_(ids),
|
||||
ChatGenerationTask.user_id == current_user.id,
|
||||
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_ASYNC.value,
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
tasks = list(result.scalars().all())
|
||||
_raise_missing_if_any(ids=ids, found_ids=[task.id for task in tasks], message="AI 创作记录不存在或已删除")
|
||||
|
||||
invalid_ids = [
|
||||
task.id
|
||||
for task in tasks
|
||||
if task.status != COMPLETED_STATUS or task.generated_at is None
|
||||
]
|
||||
_raise_invalid_if_any(invalid_ids=invalid_ids, message="AI 创作记录只有生成完成后才能删除")
|
||||
|
||||
freed_size = await soft_delete_resources_by_source(
|
||||
db,
|
||||
source_model=SOURCE_MODEL_CHAT_TASK,
|
||||
source_ids=[task.id for task in tasks],
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
for task in tasks:
|
||||
task.deleted_at = deleted_at
|
||||
|
||||
return _build_out(
|
||||
source=source,
|
||||
requested_ids=ids,
|
||||
deleted_ids=ids,
|
||||
chat_task_ids=ids,
|
||||
freed_size_bytes=freed_size,
|
||||
)
|
||||
|
||||
|
||||
async def _load_module_projects_by_ids(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
source: GenerationHistorySourceEnum,
|
||||
project_ids: list[str],
|
||||
) -> list[ModuleGenerationProject]:
|
||||
result = await db.execute(
|
||||
select(ModuleGenerationProject)
|
||||
.where(
|
||||
ModuleGenerationProject.id.in_(project_ids),
|
||||
ModuleGenerationProject.user_id == current_user.id,
|
||||
ModuleGenerationProject.module == source.value,
|
||||
ModuleGenerationProject.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
projects = list(result.scalars().all())
|
||||
_raise_missing_if_any(ids=project_ids, found_ids=[project.id for project in projects], message="模块生成项目不存在或已删除")
|
||||
return projects
|
||||
|
||||
|
||||
async def _soft_delete_module_projects(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source: GenerationHistorySourceEnum,
|
||||
projects: list[ModuleGenerationProject],
|
||||
deleted_at: datetime,
|
||||
) -> tuple[list[str], list[str], int]:
|
||||
project_ids = list(dict.fromkeys(project.id for project in projects))
|
||||
if not project_ids:
|
||||
return [], [], 0
|
||||
|
||||
step_result = await db.execute(
|
||||
select(ModuleGenerationStep)
|
||||
.where(
|
||||
ModuleGenerationStep.project_id.in_(project_ids),
|
||||
ModuleGenerationStep.module == source.value,
|
||||
ModuleGenerationStep.deleted_at.is_(None),
|
||||
ModuleGenerationStep.is_current == True,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
steps = list(step_result.scalars().all())
|
||||
task_map = await _load_chat_tasks_for_steps(db, steps)
|
||||
_assert_no_active_chat_tasks(task_map.values())
|
||||
|
||||
chat_task_ids = list(task_map.keys())
|
||||
freed_size = await soft_delete_resources_by_source(
|
||||
db,
|
||||
source_model=SOURCE_MODEL_CHAT_TASK,
|
||||
source_ids=chat_task_ids,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
|
||||
steps_by_project: dict[str, list[ModuleGenerationStep]] = {}
|
||||
for step in steps:
|
||||
steps_by_project.setdefault(step.project_id, []).append(step)
|
||||
step.deleted_at = deleted_at
|
||||
step.is_current = False
|
||||
|
||||
for task in task_map.values():
|
||||
task.deleted_at = deleted_at
|
||||
|
||||
for project in projects:
|
||||
project.deleted_at = deleted_at
|
||||
project.final_image_url = None
|
||||
project.final_video_url = None
|
||||
project.final_video_cover_url = None
|
||||
project.completed_at = None
|
||||
project_steps = steps_by_project.get(project.id, [])
|
||||
log_module_event_file(
|
||||
module=source.value,
|
||||
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
|
||||
project_id=project.id,
|
||||
user_id=project.user_id,
|
||||
message="素材云历史批量删除模块项目",
|
||||
detail={
|
||||
"source": "generation_history_batch_delete",
|
||||
"step_ids": [step.id for step in project_steps],
|
||||
"step_codes": [step.step_code for step in project_steps],
|
||||
"chat_task_ids": [step.chat_task_id for step in project_steps if step.chat_task_id],
|
||||
"refund_unfinished": False,
|
||||
},
|
||||
)
|
||||
|
||||
return project_ids, chat_task_ids, int(freed_size or 0)
|
||||
|
||||
|
||||
async def _delete_hot_opening_projects(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
source: GenerationHistorySourceEnum,
|
||||
ids: list[str],
|
||||
deleted_at: datetime,
|
||||
) -> GenerationAIHistoryBatchDeleteOut:
|
||||
projects = await _load_module_projects_by_ids(db, current_user=current_user, source=source, project_ids=ids)
|
||||
module_project_ids, chat_task_ids, freed_size = await _soft_delete_module_projects(
|
||||
db,
|
||||
source=source,
|
||||
projects=projects,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
return _build_out(
|
||||
source=source,
|
||||
requested_ids=ids,
|
||||
deleted_ids=ids,
|
||||
module_project_ids=module_project_ids,
|
||||
chat_task_ids=chat_task_ids,
|
||||
freed_size_bytes=freed_size,
|
||||
)
|
||||
|
||||
|
||||
def _assert_segments_not_active(segments: list[ShotReplicateSegment]) -> None:
|
||||
invalid_ids = [
|
||||
segment.id
|
||||
for segment in segments
|
||||
if segment.split_status in _ACTIVE_SPLIT_STATUSES
|
||||
or segment.analysis_status in _ACTIVE_SEGMENT_ANALYSIS_STATUSES
|
||||
or segment.replicate_status in _ACTIVE_SEGMENT_REPLICATE_STATUSES
|
||||
]
|
||||
_raise_invalid_if_any(invalid_ids=invalid_ids, message="拆镜片段仍在分割、分析或复刻处理中,暂不能删除")
|
||||
|
||||
|
||||
async def _delete_shot_segments(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
source: GenerationHistorySourceEnum,
|
||||
ids: list[str],
|
||||
deleted_at: datetime,
|
||||
) -> GenerationAIHistoryBatchDeleteOut:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
.where(
|
||||
ShotReplicateSegment.id.in_(ids),
|
||||
ShotReplicateSegment.user_id == current_user.id,
|
||||
ShotReplicateSegment.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
segments = list(result.scalars().all())
|
||||
_raise_missing_if_any(ids=ids, found_ids=[segment.id for segment in segments], message="拆镜复刻片段不存在或已删除")
|
||||
_assert_segments_not_active(segments)
|
||||
|
||||
missing_project_segment_ids = [segment.id for segment in segments if not segment.module_project_id]
|
||||
_raise_invalid_if_any(invalid_ids=missing_project_segment_ids, message="拆镜复刻片段尚未关联复刻项目,不能按素材云历史删除")
|
||||
|
||||
module_project_ids = list(dict.fromkeys(segment.module_project_id for segment in segments if segment.module_project_id))
|
||||
projects = await _load_module_projects_by_ids(
|
||||
db,
|
||||
current_user=current_user,
|
||||
source=source,
|
||||
project_ids=module_project_ids,
|
||||
)
|
||||
deleted_project_ids, chat_task_ids, project_freed_size = await _soft_delete_module_projects(
|
||||
db,
|
||||
source=source,
|
||||
projects=projects,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
|
||||
segment_freed_size = await soft_delete_resources_by_source(
|
||||
db,
|
||||
source_model=SOURCE_MODEL_SHOT_SEGMENT,
|
||||
source_ids=[segment.id for segment in segments],
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
for segment in segments:
|
||||
segment.deleted_at = deleted_at
|
||||
|
||||
return _build_out(
|
||||
source=source,
|
||||
requested_ids=ids,
|
||||
deleted_ids=ids,
|
||||
module_project_ids=deleted_project_ids,
|
||||
chat_task_ids=chat_task_ids,
|
||||
shot_segment_ids=ids,
|
||||
freed_size_bytes=int(project_freed_size or 0) + int(segment_freed_size or 0),
|
||||
)
|
||||
|
||||
|
||||
async def batch_delete_generation_history_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
history_source: str,
|
||||
ids: Sequence[str] | Iterable[str],
|
||||
) -> GenerationAIHistoryBatchDeleteOut:
|
||||
"""
|
||||
按素材云 history_source 批量软删除历史记录。
|
||||
事务由 get_db 统一提交/回滚;本服务只 flush,不主动 commit。
|
||||
所有分支均为“先批量查询校验,再统一软删”,任何校验失败都会整体回滚。
|
||||
"""
|
||||
try:
|
||||
source = normalize_generation_history_source(history_source)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="history_source 不支持") from exc
|
||||
|
||||
normalized_ids = _normalize_ids(ids)
|
||||
deleted_at = datetime.now(timezone.utc)
|
||||
|
||||
if source == GenerationHistorySourceEnum.GENERATION_RECORD:
|
||||
result = await _delete_generation_records(
|
||||
db,
|
||||
current_user=current_user,
|
||||
source=source,
|
||||
ids=normalized_ids,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
elif source == GenerationHistorySourceEnum.CHAT_TASK:
|
||||
result = await _delete_chat_tasks(
|
||||
db,
|
||||
current_user=current_user,
|
||||
source=source,
|
||||
ids=normalized_ids,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
elif source == GenerationHistorySourceEnum.HOT_OPENING_REPLICATE:
|
||||
result = await _delete_hot_opening_projects(
|
||||
db,
|
||||
current_user=current_user,
|
||||
source=source,
|
||||
ids=normalized_ids,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
elif source == GenerationHistorySourceEnum.SHOT_REPLICATE:
|
||||
result = await _delete_shot_segments(
|
||||
db,
|
||||
current_user=current_user,
|
||||
source=source,
|
||||
ids=normalized_ids,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="history_source 不支持")
|
||||
|
||||
# await _log_history_batch_delete(db, current_user=current_user, result=result)
|
||||
await db.flush()
|
||||
return result
|
||||
@@ -9,14 +9,17 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.common import ModuleEventTypeEnum, ModuleStepStatusEnum
|
||||
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.module_generation_project import ModuleGenerationProject
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.user import User
|
||||
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||
from app.enums.module_generation_flow import ModuleGenerationFlowConfig
|
||||
from app.services.module_generation_step_common_service import build_step_input, build_step_output, utc_now
|
||||
from app.services.resource_accounting_service import soft_delete_chat_task_resources
|
||||
from app.services.resource_accounting_service import (
|
||||
SOURCE_MODEL_CHAT_TASK,
|
||||
soft_delete_resources_by_source,
|
||||
)
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
LogModuleEventCallable = Callable[..., Awaitable[None]]
|
||||
@@ -224,6 +227,106 @@ def _clear_project_final_resources_by_deleted_steps(
|
||||
return cleared_fields
|
||||
|
||||
|
||||
ACTIVE_CHAT_TASK_BLOCK_STATUSES = {
|
||||
ChatGenerationTaskStatus.PENDING.value,
|
||||
ChatGenerationTaskStatus.GENERATING.value,
|
||||
}
|
||||
|
||||
ACTIVE_CHAT_TASK_BLOCK_STAGES = {
|
||||
ChatGenerationPipelineStage.QUEUED.value,
|
||||
ChatGenerationPipelineStage.PREPARING.value,
|
||||
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||
ChatGenerationPipelineStage.WAITING_REMOTE.value,
|
||||
ChatGenerationPipelineStage.POLLING.value,
|
||||
ChatGenerationPipelineStage.RESULT_READY.value,
|
||||
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
|
||||
ChatGenerationPipelineStage.DOWNLOADING.value,
|
||||
ChatGenerationPipelineStage.RETRY_WAITING.value,
|
||||
}
|
||||
|
||||
|
||||
def is_active_chat_generation_task(task: ChatGenerationTask) -> bool:
|
||||
"""判断 ChatGenerationTask 是否仍处于不可主动删除/废弃的处理中状态。"""
|
||||
return bool(
|
||||
task.status in ACTIVE_CHAT_TASK_BLOCK_STATUSES
|
||||
or (task.pipeline_stage in ACTIVE_CHAT_TASK_BLOCK_STAGES)
|
||||
)
|
||||
|
||||
|
||||
def _unique_ids(values: list[str | None] | tuple[str | None, ...]) -> list[str]:
|
||||
return list(dict.fromkeys(str(value) for value in values if value))
|
||||
|
||||
|
||||
async def load_chat_tasks_for_steps(
|
||||
db: AsyncSession,
|
||||
steps: list[ModuleGenerationStep],
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> dict[str, ChatGenerationTask]:
|
||||
ids = _unique_ids([step.chat_task_id for step in steps])
|
||||
if not ids:
|
||||
return {}
|
||||
stmt = select(ChatGenerationTask).where(
|
||||
ChatGenerationTask.id.in_(ids),
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
return {task.id: task for task in result.scalars().all()}
|
||||
|
||||
|
||||
async def assert_no_active_chat_tasks_for_steps(
|
||||
db: AsyncSession,
|
||||
steps: list[ModuleGenerationStep],
|
||||
*,
|
||||
detail_message: str = "当前存在生成中任务,请等待生成完成或失败后再操作",
|
||||
for_update: bool = True,
|
||||
) -> dict[str, ChatGenerationTask]:
|
||||
"""批量校验步骤关联任务是否有进行中任务。
|
||||
|
||||
用户主动修改上游步骤、删除项目、删除历史时,统一采用“生成中拦截、不退款”。
|
||||
返回已批量加载的未删除 ChatGenerationTask,调用方可继续复用,避免重复查询。
|
||||
"""
|
||||
task_map = await load_chat_tasks_for_steps(db, steps, for_update=for_update)
|
||||
active_task_ids = [task.id for task in task_map.values() if is_active_chat_generation_task(task)]
|
||||
if active_task_ids:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": detail_message,
|
||||
"active_count": len(active_task_ids),
|
||||
},
|
||||
)
|
||||
return task_map
|
||||
|
||||
|
||||
async def assert_project_has_no_active_chat_tasks(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project: ModuleGenerationProject,
|
||||
config: ModuleGenerationFlowConfig,
|
||||
detail_message: str = "当前存在生成中任务,请等待生成完成或失败后再操作",
|
||||
) -> dict[str, ChatGenerationTask]:
|
||||
result = await db.execute(
|
||||
select(ModuleGenerationStep)
|
||||
.where(
|
||||
ModuleGenerationStep.project_id == project.id,
|
||||
ModuleGenerationStep.module == config.module,
|
||||
ModuleGenerationStep.deleted_at.is_(None),
|
||||
ModuleGenerationStep.chat_task_id.is_not(None),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
steps = list(result.scalars().all())
|
||||
return await assert_no_active_chat_tasks_for_steps(
|
||||
db,
|
||||
steps,
|
||||
detail_message=detail_message,
|
||||
for_update=True,
|
||||
)
|
||||
|
||||
|
||||
async def soft_delete_steps_from_index(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -232,9 +335,17 @@ async def soft_delete_steps_from_index(
|
||||
config: ModuleGenerationFlowConfig,
|
||||
log_module_event: LogModuleEventCallable,
|
||||
deleted_at: datetime | None = None,
|
||||
refund_unfinished: bool = True,
|
||||
refund_unfinished: bool = False,
|
||||
block_active_tasks: bool = True,
|
||||
release_stats: dict[str, int] | None = None,
|
||||
) -> list[ModuleGenerationStep]:
|
||||
"""软删指定步骤及其后续当前版本步骤。
|
||||
|
||||
说明:
|
||||
- 兼容旧调用方保留 refund_unfinished 参数,但用户主动修改/删除链路不再退款。
|
||||
- 存在 pending/generating/下载中/轮询中等任务时直接 409 拦截。
|
||||
- 已完成任务只做软删任务与 generated_resources,释放容量统计;失败任务只软删任务。
|
||||
"""
|
||||
deleted_at = deleted_at or utc_now()
|
||||
result = await db.execute(
|
||||
select(ModuleGenerationStep)
|
||||
@@ -248,6 +359,21 @@ async def soft_delete_steps_from_index(
|
||||
.with_for_update()
|
||||
)
|
||||
steps = list(result.scalars().all())
|
||||
if not steps:
|
||||
return []
|
||||
|
||||
task_map = await load_chat_tasks_for_steps(db, steps, for_update=True)
|
||||
if block_active_tasks:
|
||||
active_task_ids = [task.id for task in task_map.values() if is_active_chat_generation_task(task)]
|
||||
if active_task_ids:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "当前存在生成中任务,请等待生成完成或失败后再操作",
|
||||
"active_count": len(active_task_ids),
|
||||
},
|
||||
)
|
||||
|
||||
deleted_step_codes = {step.step_code for step in steps}
|
||||
cleared_project_fields = _clear_project_final_resources_by_deleted_steps(
|
||||
project,
|
||||
@@ -255,30 +381,27 @@ async def soft_delete_steps_from_index(
|
||||
config=config,
|
||||
)
|
||||
|
||||
completed_task_ids = [
|
||||
task.id
|
||||
for task in task_map.values()
|
||||
if task.status == ChatGenerationTaskStatus.COMPLETED.value
|
||||
]
|
||||
if completed_task_ids:
|
||||
released_size = await soft_delete_resources_by_source(
|
||||
db,
|
||||
source_model=SOURCE_MODEL_CHAT_TASK,
|
||||
source_ids=completed_task_ids,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
if release_stats is not None:
|
||||
release_stats["released_size_bytes"] = int(release_stats.get("released_size_bytes", 0)) + int(released_size or 0)
|
||||
|
||||
for step in steps:
|
||||
step.is_current = False
|
||||
step.deleted_at = deleted_at
|
||||
if step.chat_task_id:
|
||||
chat_result = await db.execute(
|
||||
select(ChatGenerationTask)
|
||||
.where(ChatGenerationTask.id == step.chat_task_id, ChatGenerationTask.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
chat_task = chat_result.scalar_one_or_none()
|
||||
if chat_task:
|
||||
if chat_task.status == "completed":
|
||||
released_size = await soft_delete_chat_task_resources(db, chat_task.id, deleted_at=deleted_at)
|
||||
if release_stats is not None:
|
||||
release_stats["released_size_bytes"] = int(release_stats.get("released_size_bytes", 0)) + int(released_size or 0)
|
||||
elif refund_unfinished and chat_task.status != "failed":
|
||||
await mark_chat_generation_task_failed_and_refund_once(
|
||||
db,
|
||||
task=chat_task,
|
||||
error_message=config.cancel_chat_task_error_message,
|
||||
pipeline_stage="failed",
|
||||
)
|
||||
chat_task.deleted_at = deleted_at
|
||||
if step.chat_task_id and step.chat_task_id in task_map:
|
||||
task_map[step.chat_task_id].deleted_at = deleted_at
|
||||
|
||||
if steps:
|
||||
await log_module_event(
|
||||
db,
|
||||
@@ -289,14 +412,12 @@ async def soft_delete_steps_from_index(
|
||||
"step_ids": [step.id for step in steps],
|
||||
"step_codes": [step.step_code for step in steps],
|
||||
"cleared_project_fields": cleared_project_fields,
|
||||
"block_active_tasks": block_active_tasks,
|
||||
"refund_unfinished": False,
|
||||
},
|
||||
)
|
||||
return steps
|
||||
|
||||
|
||||
async def chat_tasks_by_id(db: AsyncSession, steps: list[ModuleGenerationStep]) -> dict[str, ChatGenerationTask]:
|
||||
ids = [step.chat_task_id for step in steps if step.chat_task_id]
|
||||
if not ids:
|
||||
return {}
|
||||
result = await db.execute(select(ChatGenerationTask).where(ChatGenerationTask.id.in_(ids)))
|
||||
return {task.id: task for task in result.scalars().all()}
|
||||
return await load_chat_tasks_for_steps(db, steps, for_update=False)
|
||||
|
||||
@@ -12,7 +12,6 @@ from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
|
||||
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus
|
||||
from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.module_generation_project import ModuleGenerationProject
|
||||
@@ -55,6 +54,7 @@ from app.services.module_generation_log_service import log_module_error, log_mod
|
||||
from app.services.llm import optimize_prompt
|
||||
from app.services.resource_accounting_service import soft_delete_chat_task_resources
|
||||
from app.services.module_generation_flow_base_service import (
|
||||
assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks,
|
||||
chat_tasks_by_id as _base_chat_tasks_by_id,
|
||||
create_module_step as _base_create_step,
|
||||
get_current_step_by_code as _base_get_current_step_by_code,
|
||||
@@ -336,7 +336,7 @@ async def _soft_delete_steps_from_index(
|
||||
project: ModuleGenerationProject,
|
||||
start_index: int,
|
||||
deleted_at: datetime | None = None,
|
||||
refund_unfinished: bool = True,
|
||||
refund_unfinished: bool = False,
|
||||
release_stats: dict[str, int] | None = None,
|
||||
) -> None:
|
||||
await _base_soft_delete_steps_from_index(
|
||||
@@ -1412,57 +1412,18 @@ async def handle_chat_generation_task_failed(db: AsyncSession, task: ChatGenerat
|
||||
|
||||
|
||||
|
||||
_ACTIVE_DELETE_BLOCK_STATUSES = {
|
||||
ChatGenerationTaskStatus.PENDING.value,
|
||||
ChatGenerationTaskStatus.GENERATING.value,
|
||||
}
|
||||
_ACTIVE_DELETE_BLOCK_STAGES = {
|
||||
ChatGenerationPipelineStage.QUEUED.value,
|
||||
ChatGenerationPipelineStage.PREPARING.value,
|
||||
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||
ChatGenerationPipelineStage.WAITING_REMOTE.value,
|
||||
ChatGenerationPipelineStage.POLLING.value,
|
||||
ChatGenerationPipelineStage.RESULT_READY.value,
|
||||
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
|
||||
ChatGenerationPipelineStage.DOWNLOADING.value,
|
||||
ChatGenerationPipelineStage.RETRY_WAITING.value,
|
||||
}
|
||||
|
||||
|
||||
async def _assert_project_has_no_active_chat_tasks_for_delete(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project: ModuleGenerationProject,
|
||||
) -> None:
|
||||
"""用户主动删除项目/切片时不退款;如仍有异步生成任务进行中,直接拦截。"""
|
||||
step_result = await db.execute(
|
||||
select(ModuleGenerationStep.chat_task_id)
|
||||
.where(
|
||||
ModuleGenerationStep.project_id == project.id,
|
||||
ModuleGenerationStep.module == MODULE,
|
||||
ModuleGenerationStep.deleted_at.is_(None),
|
||||
ModuleGenerationStep.chat_task_id.is_not(None),
|
||||
)
|
||||
await _base_assert_project_has_no_active_chat_tasks(
|
||||
db,
|
||||
project=project,
|
||||
config=FLOW_CONFIG,
|
||||
detail_message="当前拆镜复刻项目仍有生成中任务,暂不能删除",
|
||||
)
|
||||
chat_task_ids = [task_id for task_id in step_result.scalars().all() if task_id]
|
||||
if not chat_task_ids:
|
||||
return
|
||||
|
||||
task_result = await db.execute(
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
ChatGenerationTask.id.in_(chat_task_ids),
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
active_tasks = []
|
||||
for task in task_result.scalars().all():
|
||||
if task.status in _ACTIVE_DELETE_BLOCK_STATUSES or (task.pipeline_stage in _ACTIVE_DELETE_BLOCK_STAGES):
|
||||
active_tasks.append(task.id)
|
||||
|
||||
if active_tasks:
|
||||
raise HTTPException(status_code=400, detail="当前拆镜复刻项目仍有生成中任务,暂不能删除")
|
||||
|
||||
|
||||
async def mark_shot_replicate_step_dispatch_failed(
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.config import settings
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.tasks.token_refresh_task import _update_redis_token
|
||||
|
||||
#随机获取一个可用的应用配置
|
||||
async def get_available_app(open_type: int, db: AsyncSession) -> dict:
|
||||
@@ -172,10 +173,13 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
|
||||
)
|
||||
oauth = oauth.scalar_one_or_none()
|
||||
if not oauth:
|
||||
new_oauth_ids = []
|
||||
for account in account_list:
|
||||
oauth_id = generate_id()
|
||||
new_oauth_ids.append(oauth_id)
|
||||
#新增授权记录
|
||||
db.add(UserOAuth(
|
||||
id=generate_id(),
|
||||
id=oauth_id,
|
||||
account_id = str(account.get("account_id", "")),
|
||||
account_name = account.get("account_name", ""),
|
||||
account_role = account.get("account_role", ""),
|
||||
@@ -191,7 +195,11 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
|
||||
refresh_token_expired = refresh_token_expires_in,
|
||||
material_auth_status = material_auth_status,
|
||||
))
|
||||
await db.commit()
|
||||
await db.commit()
|
||||
|
||||
|
||||
for oauth_id in new_oauth_ids:
|
||||
await _update_redis_token(oauth_id, access_token, expires_in)
|
||||
else:
|
||||
# 查询现有授权记录(未删除的)
|
||||
existing_accounts = await db.execute(
|
||||
@@ -209,6 +217,9 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
|
||||
new_account_ids = {str(account.get("account_id")) for account in account_list}
|
||||
old_account_ids = set(existing_accounts.keys())
|
||||
|
||||
# 需要更新Redis的oauth_id列表
|
||||
update_redis_ids = []
|
||||
|
||||
# 1. 软删除已消失的账户
|
||||
for account_id in old_account_ids - new_account_ids:
|
||||
existing_accounts[account_id].deleted_at = datetime.now()
|
||||
@@ -226,10 +237,13 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
|
||||
existing_oauth.refresh_token = refresh_token
|
||||
existing_oauth.refresh_token_expired = refresh_token_expires_in
|
||||
existing_oauth.material_auth_status = material_auth_status
|
||||
update_redis_ids.append(existing_oauth.id)
|
||||
else:
|
||||
# 新增记录
|
||||
oauth_id = generate_id()
|
||||
update_redis_ids.append(oauth_id)
|
||||
db.add(UserOAuth(
|
||||
id=generate_id(),
|
||||
id=oauth_id,
|
||||
account_id=str(account_id),
|
||||
account_name=account.get("account_name", ""),
|
||||
account_role=account.get("account_role", ""),
|
||||
@@ -246,8 +260,45 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
|
||||
material_auth_status=material_auth_status,
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
for oauth_id in update_redis_ids:
|
||||
await _update_redis_token(oauth_id, access_token, expires_in)
|
||||
|
||||
#5.本次更新成功以后,判断是否有其他同一个appid,同一个授权登录账号的授权记录,如果有,则更新token信息
|
||||
from sqlalchemy import update
|
||||
|
||||
related_oauths = await db.execute(
|
||||
select(UserOAuth).where(
|
||||
UserOAuth.account_username == account_username,
|
||||
UserOAuth.account_userid == account_userid,
|
||||
UserOAuth.appid == app_id,
|
||||
UserOAuth.user_id != user_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
related_oauths = related_oauths.scalars().all()
|
||||
|
||||
if related_oauths:
|
||||
await db.execute(
|
||||
update(UserOAuth).where(
|
||||
UserOAuth.account_username == account_username,
|
||||
UserOAuth.account_userid == account_userid,
|
||||
UserOAuth.appid == app_id,
|
||||
UserOAuth.user_id != user_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
).values(
|
||||
access_token=access_token,
|
||||
access_token_expired=expires_in,
|
||||
refresh_token=refresh_token,
|
||||
refresh_token_expired=refresh_token_expires_in,
|
||||
material_auth_status=material_auth_status,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
for related_oauth in related_oauths:
|
||||
await _update_redis_token(related_oauth.id, access_token, expires_in)
|
||||
|
||||
#5.返回成功
|
||||
return {"message": "授权成功"}
|
||||
|
||||
async def get_kuaishou_token(app_id: str, secret: str, code: str, oauth_type: int, user_id: str, db: AsyncSession) -> dict:
|
||||
|
||||
@@ -126,12 +126,15 @@ async def submit_video_task(
|
||||
for ref in refs:
|
||||
ref_type = ref.get("type")
|
||||
ref_url = ref.get("url", "")
|
||||
ref_role = ref.get("role")
|
||||
if ref_type == "image" and ref_url:
|
||||
resolved = _resolve_url(ref_url)
|
||||
content.append({"type": "image_url", "image_url": {"url": resolved},"role":"reference_image"})
|
||||
role = ref_role if ref_role in ("first_frame", "last_frame") else "reference_image"
|
||||
content.append({"type": "image_url", "image_url": {"url": resolved}, "role": role})
|
||||
elif ref_type == "video" and ref_url:
|
||||
resolved = _resolve_url(ref_url)
|
||||
content.append({"type": "video_url", "video_url": {"url": resolved},"role":"reference_video"})
|
||||
role = ref_role if ref_role else "reference_video"
|
||||
content.append({"type": "video_url", "video_url": {"url": resolved}, "role": role})
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -60,12 +60,33 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
||||
#如果code=40103或者40107,传入refresh_token已失效,失效原因一般是由于refresh_token已被使用,或授权账号重新授权并生成了新的Token
|
||||
if data.get("code") in [40103, 40107]:
|
||||
#清空数据库中的token信息,和Redis缓存中的token
|
||||
oauth.access_token = None
|
||||
oauth.access_token_expired = None
|
||||
oauth.refresh_token = None
|
||||
oauth.refresh_token_expired = None
|
||||
from sqlalchemy import update
|
||||
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if oauth.appid:
|
||||
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
|
||||
if oauth.account_username:
|
||||
where_cond = where_cond & (UserOAuth.account_username == oauth.account_username)
|
||||
if oauth.account_userid:
|
||||
where_cond = where_cond & (UserOAuth.account_userid == oauth.account_userid)
|
||||
|
||||
await db.execute(
|
||||
update(UserOAuth).where(where_cond).values(
|
||||
access_token=None,
|
||||
access_token_expired=None,
|
||||
refresh_token=None,
|
||||
refresh_token_expired=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await _update_redis_token(oauth.id, "", None)
|
||||
|
||||
related_oauth_ids = await db.execute(
|
||||
select(UserOAuth.id).where(where_cond)
|
||||
)
|
||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||
for related_id in related_oauth_ids:
|
||||
await _update_redis_token(related_id, "", None)
|
||||
|
||||
return
|
||||
|
||||
|
||||
@@ -75,15 +96,34 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
||||
expires_in = datetime.now(tz=oauth.access_token_expired.tzinfo) + timedelta(seconds=data.get("expires_in", 0))
|
||||
refresh_token_expires_in = datetime.now(tz=oauth.refresh_token_expired.tzinfo) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
|
||||
|
||||
oauth.access_token = new_access_token
|
||||
oauth.access_token_expired = expires_in
|
||||
oauth.refresh_token = new_refresh_token
|
||||
oauth.refresh_token_expires_in = refresh_token_expires_in
|
||||
from sqlalchemy import update
|
||||
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if oauth.appid:
|
||||
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
|
||||
if oauth.account_username:
|
||||
where_cond = where_cond & (UserOAuth.account_username == oauth.account_username)
|
||||
if oauth.account_userid:
|
||||
where_cond = where_cond & (UserOAuth.account_userid == oauth.account_userid)
|
||||
|
||||
await db.execute(
|
||||
update(UserOAuth).where(where_cond).values(
|
||||
access_token=new_access_token,
|
||||
access_token_expired=expires_in,
|
||||
refresh_token=new_refresh_token,
|
||||
refresh_token_expired=refresh_token_expires_in,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await _update_redis_token(oauth.id, new_access_token, expires_in)
|
||||
related_oauth_ids = await db.execute(
|
||||
select(UserOAuth.id).where(where_cond)
|
||||
)
|
||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||
for related_id in related_oauth_ids:
|
||||
await _update_redis_token(related_id, new_access_token, expires_in)
|
||||
|
||||
logger.info(f"成功刷新巨量引擎token: oauth_id={oauth.id}, account_id={oauth.account_id}")
|
||||
logger.info(f"成功刷新巨量引擎token: oauth_id={oauth.id}, account_id={oauth.account_id}, 关联账户数={len(related_oauth_ids)}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}")
|
||||
except Exception as e:
|
||||
@@ -97,37 +137,39 @@ async def check_and_refresh_tokens():
|
||||
|
||||
query = select(UserOAuth).where(
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
UserOAuth.access_token.is_not(None),
|
||||
UserOAuth.access_token_expired.is_not(None),
|
||||
UserOAuth.refresh_token.is_not(None),
|
||||
UserOAuth.refresh_token_expired.is_not(None),
|
||||
UserOAuth.refresh_token_expired > now,
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
oauth_list = result.scalars().all()
|
||||
|
||||
refreshed_keys = set()
|
||||
|
||||
for oauth in oauth_list:
|
||||
try:
|
||||
#1.检查access_token是否过期,如果未过期,并且大于800秒,直接跳过不处理
|
||||
if not oauth.access_token_expired:
|
||||
#检查是否为支持的平台(巨量引擎)
|
||||
# port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)
|
||||
if oauth.port_type not in [1]:
|
||||
continue
|
||||
|
||||
remaining_seconds = (oauth.access_token_expired - now).total_seconds()
|
||||
#构建登录账号唯一标识,同一登录账号共享token
|
||||
key_parts = []
|
||||
if oauth.appid:
|
||||
key_parts.append(oauth.appid)
|
||||
if oauth.account_username:
|
||||
key_parts.append(oauth.account_username)
|
||||
if oauth.account_userid:
|
||||
key_parts.append(oauth.account_userid)
|
||||
login_key = "|".join(key_parts)
|
||||
|
||||
# access_token剩余时间大于等于800秒,不需要刷新
|
||||
if remaining_seconds >= REFRESH_THRESHOLD_SECONDS:
|
||||
#同一登录账号已刷新过,直接跳过(避免使用旧数据判断)
|
||||
if login_key in refreshed_keys:
|
||||
logger.debug(f"跳过重复刷新: oauth_id={oauth.id}, 同一登录账号已刷新")
|
||||
continue
|
||||
|
||||
#2.如果access_token过期,或者剩余时间小于800秒,需要刷新token
|
||||
#3.如果需要刷新token,检查refresh_token是否过期,如果refresh_token过期,说明不可刷新,需要直接重新授权,直接跳过不处理
|
||||
if not oauth.refresh_token_expired:
|
||||
continue
|
||||
|
||||
refresh_remaining_seconds = (oauth.refresh_token_expired - now).total_seconds()
|
||||
if refresh_remaining_seconds <= 0:
|
||||
continue
|
||||
|
||||
#5.获取应用配置
|
||||
#获取应用配置
|
||||
app_result = await db.execute(
|
||||
select(UserOAuthApp).where(UserOAuthApp.app_id == oauth.appid)
|
||||
)
|
||||
@@ -135,12 +177,31 @@ async def check_and_refresh_tokens():
|
||||
|
||||
if not app:
|
||||
continue
|
||||
|
||||
#检查是否为支持的平台(巨量引擎)
|
||||
# port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)
|
||||
if oauth.port_type in [1]:
|
||||
#刷新token
|
||||
await refresh_juliang_token(oauth, app, db)
|
||||
|
||||
#检查access_token是否需要刷新
|
||||
need_refresh = False
|
||||
|
||||
# access_token为空,需要刷新
|
||||
if not oauth.access_token:
|
||||
need_refresh = True
|
||||
# access_token_expired为空,需要刷新
|
||||
elif not oauth.access_token_expired:
|
||||
need_refresh = True
|
||||
# access_token即将过期(剩余时间小于800秒),需要刷新
|
||||
else:
|
||||
remaining_seconds = (oauth.access_token_expired - now).total_seconds()
|
||||
if remaining_seconds < REFRESH_THRESHOLD_SECONDS:
|
||||
need_refresh = True
|
||||
|
||||
if not need_refresh:
|
||||
continue
|
||||
|
||||
#refresh_token已在查询条件中过滤,确保有效才能刷新
|
||||
|
||||
#刷新token
|
||||
await refresh_juliang_token(oauth, app, db)
|
||||
|
||||
refreshed_keys.add(login_key)
|
||||
|
||||
except Exception as e:
|
||||
#7.增加错误日志
|
||||
|
||||
@@ -89,7 +89,6 @@ class DouyinRequest:
|
||||
if token:
|
||||
return token
|
||||
|
||||
|
||||
async with async_session() as db:
|
||||
oauth_data = await db.execute(
|
||||
select(UserOAuth).where(
|
||||
@@ -103,17 +102,30 @@ class DouyinRequest:
|
||||
raise ValueError("无效的oauth_id")
|
||||
|
||||
if force_refresh:
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if oauth_data.appid:
|
||||
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
|
||||
if oauth_data.account_username:
|
||||
where_cond = where_cond & (UserOAuth.account_username == oauth_data.account_username)
|
||||
if oauth_data.account_userid:
|
||||
where_cond = where_cond & (UserOAuth.account_userid == oauth_data.account_userid)
|
||||
|
||||
await db.execute(
|
||||
update(UserOAuth).where(UserOAuth.id == oauth_id).values(
|
||||
update(UserOAuth).where(where_cond).values(
|
||||
access_token=None,
|
||||
access_token_expired=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await self._delete_redis_token(oauth_id)
|
||||
|
||||
related_oauth_ids = await db.execute(
|
||||
select(UserOAuth.id).where(where_cond)
|
||||
)
|
||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||
for related_id in related_oauth_ids:
|
||||
await self._delete_redis_token(related_id)
|
||||
|
||||
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||
await self._set_redis_token(oauth_id, new_token, new_expired_at)
|
||||
return new_token
|
||||
|
||||
if oauth_data.access_token_expired and oauth_data.access_token_expired > datetime.now(timezone.utc):
|
||||
@@ -122,14 +134,46 @@ class DouyinRequest:
|
||||
await self._set_redis_token(oauth_id, token, expired_at)
|
||||
return token
|
||||
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if oauth_data.appid:
|
||||
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
|
||||
if oauth_data.account_username:
|
||||
where_cond = where_cond & (UserOAuth.account_username == oauth_data.account_username)
|
||||
if oauth_data.account_userid:
|
||||
where_cond = where_cond & (UserOAuth.account_userid == oauth_data.account_userid)
|
||||
|
||||
related_oauths = await db.execute(
|
||||
select(UserOAuth.access_token, UserOAuth.access_token_expired).where(
|
||||
where_cond,
|
||||
UserOAuth.access_token_expired.is_not(None),
|
||||
UserOAuth.access_token_expired > datetime.now(timezone.utc),
|
||||
).limit(1)
|
||||
)
|
||||
related_oauth = related_oauths.first()
|
||||
if related_oauth:
|
||||
token, expired_at = related_oauth
|
||||
await self._set_redis_token(oauth_id, token, expired_at)
|
||||
return token
|
||||
|
||||
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < datetime.now(timezone.utc):
|
||||
raise ValueError("授权已过期,请重新授权")
|
||||
|
||||
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||
await self._set_redis_token(oauth_id, new_token, new_expired_at)
|
||||
return new_token
|
||||
|
||||
async def refresh_access_token(self, db: AsyncSession, oauth_id: str, appid: str, refresh_token: str) -> Tuple[str, datetime]:
|
||||
oauth_info = await db.execute(
|
||||
select(UserOAuth.account_username, UserOAuth.account_userid).where(
|
||||
UserOAuth.id == oauth_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
oauth_info = oauth_info.first()
|
||||
if not oauth_info:
|
||||
raise ValueError("无效的oauth_id")
|
||||
|
||||
account_username, account_userid = oauth_info
|
||||
|
||||
result = await db.execute(
|
||||
select(UserOAuthApp.secret).where(
|
||||
UserOAuthApp.app_id == appid,
|
||||
@@ -169,9 +213,17 @@ class DouyinRequest:
|
||||
|
||||
new_expired_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
||||
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if appid:
|
||||
where_cond = where_cond & (UserOAuth.appid == appid)
|
||||
if account_username:
|
||||
where_cond = where_cond & (UserOAuth.account_username == account_username)
|
||||
if account_userid:
|
||||
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
|
||||
|
||||
await db.execute(
|
||||
update(UserOAuth).where(
|
||||
UserOAuth.id == oauth_id,
|
||||
where_cond,
|
||||
).values(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
@@ -181,6 +233,14 @@ class DouyinRequest:
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
related_oauth_ids = await db.execute(
|
||||
select(UserOAuth.id).where(where_cond)
|
||||
)
|
||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||
|
||||
for related_id in related_oauth_ids:
|
||||
await self._set_redis_token(related_id, new_access_token, new_expired_at)
|
||||
|
||||
return new_access_token, new_expired_at
|
||||
|
||||
# 有token请求
|
||||
@@ -267,19 +327,6 @@ class DouyinRequest:
|
||||
else:
|
||||
raise ValueError('网络错误,稍后重试。')
|
||||
|
||||
# options_log = {}
|
||||
# if options:
|
||||
# for key, value in options.items():
|
||||
# if key == 'files':
|
||||
# options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
|
||||
# else:
|
||||
# options_log[key] = value
|
||||
# raise RuntimeError(
|
||||
# f'DouYin API request failed after 5 retries. '
|
||||
# f'url:{url};oauthId:{oauth_id};options:{json.dumps(options_log)};response:{res}'
|
||||
# )
|
||||
# if code != 0:
|
||||
# raise ValueError(f'response:{res}')
|
||||
|
||||
# 无token请求
|
||||
async def request_with_context(
|
||||
|
||||
+442
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -28,8 +28,8 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-B5pqnZWD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BhPFzWLH.css">
|
||||
<script type="module" crossorigin src="/assets/index-dEsTkAeV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bi8mcSs8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -65,6 +65,8 @@ interface MediaReference {
|
||||
type: 'image' | 'video';
|
||||
url: string;
|
||||
duration?: number;
|
||||
role?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -156,6 +158,13 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const [referenceMode, setReferenceMode] = useState<'universal' | 'first_last_frame'>('universal');
|
||||
const [firstFrame, setFirstFrame] = useState<MediaReference | null>(null);
|
||||
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
|
||||
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
|
||||
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
|
||||
const [mediaStackHovered, setMediaStackHovered] = useState(false);
|
||||
|
||||
// @ 提及相关状态
|
||||
const [mentionVisible, setMentionVisible] = useState(false);
|
||||
const mentionInputRef = useRef<any>(null);
|
||||
@@ -408,6 +417,24 @@ const AIChatPage: React.FC = () => {
|
||||
};
|
||||
}, [showEngineModal, showImageSettingsModal, showVideoSettingsModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaType !== 'video') return;
|
||||
const engine = enginesele?.video?.find((e: any) => e.id === countType);
|
||||
if (!engine) return;
|
||||
|
||||
const supportsFLF = engine.supportsFirstLastFrame ?? false;
|
||||
const supportsUR = engine.supportsUniversalReference ?? true;
|
||||
|
||||
setReferenceMode((prevMode) => {
|
||||
if (prevMode === 'first_last_frame' && !supportsFLF) {
|
||||
if (supportsUR) return 'universal';
|
||||
} else if (prevMode === 'universal' && !supportsUR) {
|
||||
if (supportsFLF) return 'first_last_frame';
|
||||
}
|
||||
return prevMode;
|
||||
});
|
||||
}, [countType, mediaType, enginesele]);
|
||||
|
||||
// 初始化获取参数 - 只在组件挂载时执行一次
|
||||
useEffect(() => {
|
||||
getEngine()
|
||||
@@ -648,6 +675,31 @@ const AIChatPage: React.FC = () => {
|
||||
setHeight(width);
|
||||
};
|
||||
|
||||
const currentVideoEngine = enginesele?.video?.find((e: any) => e.id === countType);
|
||||
const supportsFirstLastFrame = currentVideoEngine?.supportsFirstLastFrame ?? false;
|
||||
const supportsUniversalReference = currentVideoEngine?.supportsUniversalReference ?? true;
|
||||
|
||||
const handleReferenceModeChange = (mode: 'universal' | 'first_last_frame') => {
|
||||
if (mode === 'first_last_frame' && !supportsFirstLastFrame) return;
|
||||
if (mode === 'universal' && !supportsUniversalReference) return;
|
||||
setReferenceMode(mode);
|
||||
setReferenceModeDropdownVisible(false);
|
||||
};
|
||||
|
||||
const handleSwapFrames = () => {
|
||||
const temp = firstFrame;
|
||||
setFirstFrame(lastFrame);
|
||||
setLastFrame(temp);
|
||||
};
|
||||
|
||||
const handleRemoveFirstFrame = () => {
|
||||
setFirstFrame(null);
|
||||
};
|
||||
|
||||
const handleRemoveLastFrame = () => {
|
||||
setLastFrame(null);
|
||||
};
|
||||
|
||||
// ==================== 事处理函数 ====================
|
||||
|
||||
const handleNewChat = () => {
|
||||
@@ -668,6 +720,8 @@ const AIChatPage: React.FC = () => {
|
||||
// 清空输入框和已上传媒体
|
||||
setInputValue('');
|
||||
setCurrentMedia([]);
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
// 显示提示消息
|
||||
message.info('已开启新对话');
|
||||
|
||||
@@ -693,14 +747,44 @@ const AIChatPage: React.FC = () => {
|
||||
const handleSelectChat = (conversationId: string) => {
|
||||
setCurrentConversationId(conversationId);
|
||||
setCurrentMedia([]);
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
};
|
||||
|
||||
|
||||
const handleSend = async () => {
|
||||
// 验证:必须有内容或图片或视频
|
||||
if (!inputValue.trim() && currentMedia.length === 0) {
|
||||
message.warning('请输入内容或上传图片/视频');
|
||||
return;
|
||||
const isFirstLastFrameMode = mediaType === 'video' && referenceMode === 'first_last_frame';
|
||||
|
||||
if (isFirstLastFrameMode) {
|
||||
if (!inputValue.trim() && !firstFrame) {
|
||||
message.warning('请输入内容或上传首帧图片');
|
||||
return;
|
||||
}
|
||||
if (!firstFrame) {
|
||||
message.warning('请上传首帧图片');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!inputValue.trim() && currentMedia.length === 0) {
|
||||
message.warning('请输入内容或上传图片/视频');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mediaReferences: MediaReference[] | undefined;
|
||||
if (isFirstLastFrameMode) {
|
||||
mediaReferences = [];
|
||||
if (firstFrame) {
|
||||
mediaReferences.push({ ...firstFrame, role: 'first_frame' });
|
||||
}
|
||||
if (lastFrame) {
|
||||
mediaReferences.push({ ...lastFrame, role: 'last_frame' });
|
||||
}
|
||||
if (mediaReferences.length === 0) {
|
||||
mediaReferences = undefined;
|
||||
}
|
||||
} else {
|
||||
mediaReferences = currentMedia.length > 0 ? [...currentMedia] : undefined;
|
||||
}
|
||||
|
||||
// 创建用户消息对象
|
||||
@@ -711,8 +795,7 @@ const AIChatPage: React.FC = () => {
|
||||
engine_id: countType,
|
||||
|
||||
idempotency_key: new Date().toLocaleString('zh-CN'),
|
||||
// 统一的媒体数组,包含 name、type、url
|
||||
media_references: currentMedia.length > 0 ? [...currentMedia] : undefined,
|
||||
media_references: mediaReferences,
|
||||
// 图片参数(仅图片模式时添加)
|
||||
...(mediaType === 'image' && {
|
||||
image_size: selectedResolution,
|
||||
@@ -739,6 +822,8 @@ const AIChatPage: React.FC = () => {
|
||||
// 创建任务成功后,清空输入框和已上传媒体
|
||||
setInputValue('');
|
||||
setCurrentMedia([]);
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
|
||||
// 创建任务成功后,重置页数为1,获取最新列表
|
||||
const newPagebreak = { ...Pagebreak, page: 1 };
|
||||
@@ -866,49 +951,79 @@ const AIChatPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
// 验证文件类型
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (mediaType === 'video' && referenceMode === 'first_last_frame') {
|
||||
if (!isImage) {
|
||||
message.error('首尾帧模式仅支持上传图片');
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 10) {
|
||||
message.error('图片大小不能超过10MB');
|
||||
return false;
|
||||
}
|
||||
if (!uploadTarget) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await uploadImage(file);
|
||||
const mediaRef: MediaReference = {
|
||||
name: file.name,
|
||||
type: 'image',
|
||||
url: res.url,
|
||||
role: uploadTarget === 'first' ? 'first_frame' : 'last_frame',
|
||||
};
|
||||
if (uploadTarget === 'first') {
|
||||
setFirstFrame(mediaRef);
|
||||
} else {
|
||||
setLastFrame(mediaRef);
|
||||
}
|
||||
message.success('图片上传成功');
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadTarget(null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isImage && !isVideo) {
|
||||
message.error('仅支持图片或视频文件');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取当前选中引擎的限制
|
||||
const currentEngineList = mediaType === 'image' ? enginesele.image : enginesele.video;
|
||||
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
|
||||
const maxImage = currentEngine?.maxImageCount ?? 4;
|
||||
const maxVideo = currentEngine?.maxVideoCount ?? 1;
|
||||
|
||||
// 根据 mediaType 判断是否允许该文件类型
|
||||
if (mediaType === 'image' && isVideo) {
|
||||
message.error('图片模式仅支持上传图片');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
const maxMB = isVideo ? 100 : 10;
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
message.error(`${isVideo ? '视频' : '图片'}大小不能超过${maxMB}MB`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证图片数量
|
||||
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
|
||||
if (isImage && imageCount >= maxImage) {
|
||||
message.error(`该引擎最多上传${maxImage}张图片`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证视频数量
|
||||
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
|
||||
if (isVideo && videoCount >= maxVideo) {
|
||||
message.error(`该引擎最多上传${maxVideo}个视频`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取视频时长并验证
|
||||
let videoDuration = 0;
|
||||
if (isVideo) {
|
||||
try {
|
||||
@@ -930,14 +1045,11 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 设置上传状态
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// 根据文件类型调用相应的上传函数
|
||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||
const res = await uploadFn(file);
|
||||
// 添加到统一的媒体列表
|
||||
const mediaType: 'image' | 'video' = isImage ? 'image' : 'video';
|
||||
const newList = [...currentMedia, {
|
||||
name: file.name,
|
||||
@@ -955,7 +1067,6 @@ const AIChatPage: React.FC = () => {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
// 返回false阻止Ant Design的自动上传行为
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1341,14 +1452,30 @@ const AIChatPage: React.FC = () => {
|
||||
e.stopPropagation();
|
||||
setInputValue(msg.originalPrompt || '');
|
||||
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
|
||||
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
|
||||
name: ref.name,
|
||||
type: ref.type,
|
||||
url: ref.url,
|
||||
label: ref.label || '',
|
||||
})));
|
||||
const hasFirstLastFrame = msg.mediaReferences.some((ref: any) => ref.role === 'first_frame' || ref.role === 'last_frame');
|
||||
if (hasFirstLastFrame && msg.genType === 'video') {
|
||||
const first = msg.mediaReferences.find((ref: any) => ref.role === 'first_frame');
|
||||
const last = msg.mediaReferences.find((ref: any) => ref.role === 'last_frame');
|
||||
setFirstFrame(first ? { ...first, label: first.label || '' } : null);
|
||||
setLastFrame(last ? { ...last, label: last.label || '' } : null);
|
||||
setCurrentMedia([]);
|
||||
setReferenceMode('first_last_frame');
|
||||
} else {
|
||||
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
|
||||
name: ref.name,
|
||||
type: ref.type,
|
||||
url: ref.url,
|
||||
label: ref.label || '',
|
||||
role: ref.role,
|
||||
})));
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
setReferenceMode('universal');
|
||||
}
|
||||
} else {
|
||||
setCurrentMedia([]);
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
}
|
||||
msgApi.success('已加载到编辑区');
|
||||
}}
|
||||
@@ -1680,8 +1807,8 @@ const AIChatPage: React.FC = () => {
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: 24,
|
||||
padding: 16,
|
||||
borderRadius: 28,
|
||||
padding: 20,
|
||||
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)',
|
||||
@@ -1689,63 +1816,275 @@ const AIChatPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
{/* 已上传媒体预览 */}
|
||||
{currentMedia.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, overflowX: 'auto' }}>
|
||||
{currentMedia.map((media, idx) => (
|
||||
<div key={`media-${idx}`} style={{ position: 'relative', width: media.type === 'video' ? 120 : 80, flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
{media.type === 'image' ? (
|
||||
{mediaType === 'video' && referenceMode === 'first_last_frame' ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 16, marginBottom: 16, padding: '8px 0' }}>
|
||||
{/* 首帧 */}
|
||||
<div style={{ position: 'relative', width: 100, height: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ position: 'absolute', top: -20, left: 0, fontSize: 11, fontWeight: 600, color: '#6366f1', zIndex: 10 }}>首帧</span>
|
||||
{firstFrame ? (
|
||||
<div style={{ position: 'relative', width: 100, height: 100 }}>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
alt={media.name}
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${firstFrame.url}`}
|
||||
alt={firstFrame.name}
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewUrl(firstFrame.url);
|
||||
setAttachmentPreviewType('image');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewName(firstFrame.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
|
||||
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(99, 102, 241, 0.3)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
muted
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewType('video');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
<button
|
||||
onClick={handleRemoveFirstFrame}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
width: 22,
|
||||
height: 22,
|
||||
border: 'none',
|
||||
background: '#ef4444',
|
||||
borderRadius: 50,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.4)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 500 }}>
|
||||
{media.label}
|
||||
</span>
|
||||
{/* 删除已上传媒体按钮 */}
|
||||
<button
|
||||
onClick={() => handleRemoveMedia(idx)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: 'none',
|
||||
background: '#ff4d4f',
|
||||
borderRadius: 50,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 11 }} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => { setUploadTarget('first'); return handleUpload(file); }}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 12,
|
||||
border: '2px dashed rgba(99, 102, 241, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.04)',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.08)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.3)';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.04)';
|
||||
}}
|
||||
>
|
||||
<PlusOutlined style={{ fontSize: 20, color: '#6366f1' }} />
|
||||
<span style={{ fontSize: 10, color: '#6366f1', fontWeight: 500 }}>上传首帧</span>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 调换按钮 */}
|
||||
<button
|
||||
onClick={handleSwapFrames}
|
||||
disabled={!firstFrame || !lastFrame}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 50,
|
||||
border: 'none',
|
||||
background: firstFrame && lastFrame ? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)' : '#e2e8f0',
|
||||
cursor: firstFrame && lastFrame ? 'pointer' : 'not-allowed',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: firstFrame && lastFrame ? '0 4px 12px rgba(99, 102, 241, 0.4)' : 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (firstFrame && lastFrame) {
|
||||
e.currentTarget.style.transform = 'scale(1.1)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<SwapOutlined style={{ fontSize: 16 }} />
|
||||
</button>
|
||||
|
||||
{/* 尾帧 */}
|
||||
<div style={{ position: 'relative', width: 100, height: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ position: 'absolute', top: -20, left: 0, fontSize: 11, fontWeight: 600, color: '#8b5cf6', zIndex: 10 }}>尾帧</span>
|
||||
{lastFrame ? (
|
||||
<div style={{ position: 'relative', width: 100, height: 100 }}>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${lastFrame.url}`}
|
||||
alt={lastFrame.name}
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(lastFrame.url);
|
||||
setAttachmentPreviewType('image');
|
||||
setAttachmentPreviewName(lastFrame.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(139, 92, 246, 0.3)', boxShadow: '0 4px 12px rgba(139, 92, 246, 0.15)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRemoveLastFrame}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
width: 22,
|
||||
height: 22,
|
||||
border: 'none',
|
||||
background: '#ef4444',
|
||||
borderRadius: 50,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.4)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 11 }} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => { setUploadTarget('last'); return handleUpload(file); }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 12,
|
||||
border: '2px dashed rgba(139, 92, 246, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.04)',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#8b5cf6';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.3)';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
|
||||
}}
|
||||
>
|
||||
<PlusOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />
|
||||
<span style={{ fontSize: 10, color: '#8b5cf6', fontWeight: 500 }}>上传尾帧</span>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
currentMedia.length > 0 && (
|
||||
<div
|
||||
style={{ position: 'relative', marginBottom: 12, minHeight: 90, paddingLeft: 8 }}
|
||||
onMouseEnter={() => setMediaStackHovered(true)}
|
||||
onMouseLeave={() => setMediaStackHovered(false)}
|
||||
>
|
||||
<div style={{ display: 'flex', position: 'relative' }}>
|
||||
{currentMedia.map((media, idx) => {
|
||||
const reversedIdx = currentMedia.length - 1 - idx;
|
||||
const offset = mediaStackHovered ? idx * 88 : idx * 4;
|
||||
return (
|
||||
<div
|
||||
key={`media-${idx}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: offset,
|
||||
top: 0,
|
||||
zIndex: idx + 1,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: `scale(${1 - reversedIdx * 0.03})`,
|
||||
opacity: mediaStackHovered ? 1 : (1 - reversedIdx * 0.15),
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', width: media.type === 'video' ? 100 : 80, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
{media.type === 'image' ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
alt={media.name}
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewType('image');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.8)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
muted
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewType('video');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.8)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontSize: 10, color: '#64748b', fontWeight: 500 }}>
|
||||
{media.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveMedia(idx)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: 'none',
|
||||
background: '#ef4444',
|
||||
borderRadius: 50,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(239, 68, 68, 0.4)',
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 10 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 输入框区域 */}
|
||||
@@ -1753,57 +2092,59 @@ const AIChatPage: React.FC = () => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 16px',
|
||||
padding: '14px 18px',
|
||||
backgroundColor: 'rgba(248,250,252,0.6)',
|
||||
borderRadius: 16,
|
||||
borderRadius: 18,
|
||||
border: '1px solid rgba(99, 102, 241, 0.08)',
|
||||
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* 上传按钮 */}
|
||||
<Upload
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
|
||||
}>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
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: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.06)';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
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)';
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
) : (
|
||||
<PlusOutlined style={{ fontSize: 18, color: '#64748b' }} />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
{/* 上传按钮 - 首尾帧模式下隐藏 */}
|
||||
{!(mediaType === 'video' && referenceMode === 'first_last_frame') && (
|
||||
<Upload
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
|
||||
}>
|
||||
<div
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
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: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.06)';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
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)';
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 20, color: '#6366f1' }} />
|
||||
) : (
|
||||
<PlusOutlined style={{ fontSize: 20, color: '#64748b' }} />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
)}
|
||||
|
||||
{/* 文本输入框 */}
|
||||
<TextArea
|
||||
@@ -1814,7 +2155,9 @@ const AIChatPage: React.FC = () => {
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={mediaType === 'image'
|
||||
? `上传最多${maxImageCount}张参考图,输入提示词描述您想生成的画面...`
|
||||
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
|
||||
: referenceMode === 'first_last_frame'
|
||||
? '上传首帧图片(必填)和尾帧图片(可选),输入提示词描述您想生成的视频...'
|
||||
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
|
||||
}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{
|
||||
@@ -1824,15 +2167,16 @@ const AIChatPage: React.FC = () => {
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
boxShadow: 'none',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.5,
|
||||
fontSize: 15,
|
||||
lineHeight: 1.6,
|
||||
color: '#1e293b',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* @ 提及下拉列表 */}
|
||||
{mentionVisible && currentMedia.length > 0 && (
|
||||
{mentionVisible && currentMedia.length > 0 && referenceMode === 'universal' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -1891,18 +2235,30 @@ const AIChatPage: React.FC = () => {
|
||||
shape="circle"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() && currentMedia.length === 0}
|
||||
disabled={
|
||||
mediaType === 'video' && referenceMode === 'first_last_frame'
|
||||
? !inputValue.trim() && !firstFrame
|
||||
: !inputValue.trim() && currentMedia.length === 0
|
||||
}
|
||||
loading={loading}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
background: (inputValue.trim() || currentMedia.length > 0)
|
||||
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
|
||||
background: (
|
||||
mediaType === 'video' && referenceMode === 'first_last_frame'
|
||||
? (inputValue.trim() || firstFrame)
|
||||
: (inputValue.trim() || currentMedia.length > 0)
|
||||
)
|
||||
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
|
||||
: '#c7cfdaff',
|
||||
boxShadow: (inputValue.trim() || currentMedia.length > 0)
|
||||
? '0 4px 16px rgba(99, 102, 241, 0.4)'
|
||||
boxShadow: (
|
||||
mediaType === 'video' && referenceMode === 'first_last_frame'
|
||||
? (inputValue.trim() || firstFrame)
|
||||
: (inputValue.trim() || currentMedia.length > 0)
|
||||
)
|
||||
? '0 4px 16px rgba(99, 102, 241, 0.4)'
|
||||
: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
border: 'none',
|
||||
@@ -2089,6 +2445,170 @@ const AIChatPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 参考模式切换按钮 - 仅视频模式显示 */}
|
||||
{mediaType === 'video' && (supportsFirstLastFrame || supportsUniversalReference) && (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
onClick={() => setReferenceModeDropdownVisible(!referenceModeDropdownVisible)}
|
||||
className="image-settings-trigger"
|
||||
style={{
|
||||
minWidth: 120,
|
||||
padding: '6px 14px',
|
||||
height: 34,
|
||||
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.boxShadow = '0 4px 12px rgba(99, 102, 241, 0.08)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(99, 102, 241, 0.04)';
|
||||
}}
|
||||
>
|
||||
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
|
||||
<Text style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: '#6366f1',
|
||||
}}>
|
||||
{referenceMode === 'universal' ? '全能参考' : '首尾帧'}
|
||||
</Text>
|
||||
<CaretDownOutlined style={{ fontSize: 10, color: '#6366f1', marginLeft: 2 }} />
|
||||
</button>
|
||||
|
||||
{referenceModeDropdownVisible && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9998,
|
||||
}}
|
||||
onClick={() => setReferenceModeDropdownVisible(false)}
|
||||
/>
|
||||
<div
|
||||
className="image-settings-popover"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: 0,
|
||||
minWidth: 160,
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
|
||||
padding: 8,
|
||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleReferenceModeChange('universal')}
|
||||
disabled={!supportsUniversalReference}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: referenceMode === 'universal'
|
||||
? '1px solid rgba(99, 102, 241, 0.3)'
|
||||
: '1px solid transparent',
|
||||
backgroundColor: referenceMode === 'universal'
|
||||
? 'rgba(99, 102, 241, 0.08)'
|
||||
: 'transparent',
|
||||
cursor: supportsUniversalReference ? 'pointer' : 'not-allowed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
transition: 'all 0.2s',
|
||||
textAlign: 'left',
|
||||
opacity: supportsUniversalReference ? 1 : 0.4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (supportsUniversalReference && referenceMode !== 'universal') {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.04)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (referenceMode !== 'universal') {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
fontWeight: referenceMode === 'universal' ? 600 : 500,
|
||||
color: referenceMode === 'universal' ? '#6366f1' : '#4b5563',
|
||||
flex: 1,
|
||||
}}>
|
||||
全能参考
|
||||
</span>
|
||||
{!supportsUniversalReference && (
|
||||
<span style={{ fontSize: 10, color: '#9ca3af' }}>不支持</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReferenceModeChange('first_last_frame')}
|
||||
disabled={!supportsFirstLastFrame}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: referenceMode === 'first_last_frame'
|
||||
? '1px solid rgba(139, 92, 246, 0.3)'
|
||||
: '1px solid transparent',
|
||||
backgroundColor: referenceMode === 'first_last_frame'
|
||||
? 'rgba(139, 92, 246, 0.08)'
|
||||
: 'transparent',
|
||||
cursor: supportsFirstLastFrame ? 'pointer' : 'not-allowed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
transition: 'all 0.2s',
|
||||
textAlign: 'left',
|
||||
opacity: supportsFirstLastFrame ? 1 : 0.4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (supportsFirstLastFrame && referenceMode !== 'first_last_frame') {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (referenceMode !== 'first_last_frame') {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SwapOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
fontWeight: referenceMode === 'first_last_frame' ? 600 : 500,
|
||||
color: referenceMode === 'first_last_frame' ? '#8b5cf6' : '#4b5563',
|
||||
flex: 1,
|
||||
}}>
|
||||
首尾帧
|
||||
</span>
|
||||
{!supportsFirstLastFrame && (
|
||||
<span style={{ fontSize: 10, color: '#9ca3af' }}>不支持</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片设置按钮 */}
|
||||
{mediaType === 'image' && (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
|
||||
Reference in New Issue
Block a user