解决冲突

This commit is contained in:
Lrd
2026-07-01 17:59:05 +08:00
46 changed files with 3656 additions and 474 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-Yj3EUzSB.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CbFNd7rH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -217,6 +217,7 @@ const AdminGenerationRecords: React.FC = () => {
username: item.username,
projectId: item.projectId,
projectName: item.projectName,
industry: item.industry,
originalPrompt: item.originalPrompt,
optimizedPrompt: item.optimizedPrompt,
duration: item.duration,
@@ -390,6 +391,10 @@ const AdminGenerationRecords: React.FC = () => {
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
render: (v: string) => <Typography.Text style={{ fontSize: 13 }}>{v || '-'}</Typography.Text>,
},
{
title: '行业', dataIndex: 'industry', width: 100, ellipsis: true,
render: (v: string) => <Tag color="cyan">{v || '-'}</Tag>,
},
{
title: '类型', dataIndex: 'genType', width: 90, ellipsis: true,
render: (v: string) => {
@@ -17,6 +17,7 @@ interface ImageEngine {
supportedModels: string[];
supportedSizes: Record<string, Record<string, string>>;
defaultSize: string;
maxImageCount: number;
generateUrl: string;
isActive: boolean;
priority: number;
@@ -111,6 +112,7 @@ const AdminImageEngines: React.FC = () => {
supported_models: JSON.stringify(values.supportedModels || []),
supported_sizes: JSON.stringify(sizes),
default_size: values.defaultSize || '2K',
max_image_count: values.maxImageCount ?? 0,
generate_url: values.generateUrl || '',
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
@@ -158,6 +160,7 @@ const AdminImageEngines: React.FC = () => {
isActive: true, priority: 0,
supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K',
maxImageCount: 0,
size_2K: ALL_RATIOS,
size_4K: ALL_RATIOS,
});
@@ -204,6 +207,10 @@ const AdminImageEngines: React.FC = () => {
))}</Space>;
},
},
{
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
render: (v: number) => <Tag color="purple">{v} </Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -315,6 +322,9 @@ const AdminImageEngines: React.FC = () => {
{ value: '4K', label: '4K' },
]} />
</Form.Item>
<Form.Item name="maxImageCount" label="最大图片数量">
<Input type="number" size="large" />
</Form.Item>
<Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item>
+1 -1
View File
@@ -67,7 +67,7 @@ const AdminLayout: React.FC = () => {
const logo = info.siteLogo || '';
setSiteName(name);
setSiteLogo(logo);
document.title = name;
document.title = name === '管理后台' ? name : name + ' - 管理后台';
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
if (logo) {
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
@@ -17,6 +17,9 @@ interface VideoEngine {
supportedRatios: string[];
supportedResolutions: string[];
supportedDurations: number[];
maxDuration: number;
maxImageCount: number;
maxVideoCount: number;
isActive: boolean;
priority: number;
}
@@ -66,6 +69,9 @@ const AdminVideoEngines: React.FC = () => {
supported_ratios: JSON.stringify(values.supportedRatios || []),
supported_resolutions: JSON.stringify(values.supportedResolutions || []),
supported_durations: JSON.stringify(values.supportedDurations || []),
max_duration: values.maxDuration ?? 15,
max_image_count: values.maxImageCount ?? 2,
max_video_count: values.maxVideoCount ?? 0,
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
};
@@ -102,11 +108,14 @@ const AdminVideoEngines: React.FC = () => {
} else {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
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],
});
isActive: true, priority: 0,
maxDuration: 15,
maxImageCount: 2,
maxVideoCount: 0,
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],
});
}
};
@@ -142,6 +151,14 @@ const AdminVideoEngines: React.FC = () => {
title: '支持时长', dataIndex: 'supportedDurations', width: 120,
render: (d: number[]) => <Tag color="orange">{d?.length ? `${Math.min(...d)}-${Math.max(...d)}s` : '-'}</Tag>,
},
{
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
render: (v: number) => <Tag color="purple">{v} </Tag>,
},
{
title: '最大视频', dataIndex: 'maxVideoCount', width: 100,
render: (v: number) => <Tag color="cyan">{v} </Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -234,6 +251,17 @@ const AdminVideoEngines: React.FC = () => {
} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="maxDuration" label="最大时长(秒)" style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
<Form.Item name="maxImageCount" label="最大图片数量" style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
<Form.Item name="maxVideoCount" label="最大视频数量" style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Select size="large" options={[
+1
View File
@@ -319,6 +319,7 @@ export interface AdminGenerationRecord {
username: string;
projectId: string;
projectName: string;
industry?: string;
originalPrompt: string;
optimizedPrompt: string;
duration?: number;
@@ -0,0 +1,25 @@
"""add max image count to image engines
Revision ID: a1b2c3d4e5f6
Revises: f7a3b2c1d4e5
Create Date: 2026-07-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a1b2c3d4e5f7'
down_revision: Union[str, None] = 'f7a3b2c1d4e5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('image_engines', sa.Column('max_image_count', sa.Integer(), server_default='0', nullable=False))
def downgrade() -> None:
op.drop_column('image_engines', 'max_image_count')
@@ -0,0 +1,27 @@
"""add max image and video count to video engines
Revision ID: f7a3b2c1d4e5
Revises: e5f260ed1459
Create Date: 2026-07-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'f7a3b2c1d4e5'
down_revision: Union[str, None] = 'e5f260ed1459'
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('max_image_count', sa.Integer(), server_default='2', nullable=False))
op.add_column('video_engines', sa.Column('max_video_count', sa.Integer(), server_default='0', nullable=False))
def downgrade() -> None:
op.drop_column('video_engines', 'max_video_count')
op.drop_column('video_engines', 'max_image_count')
+4 -2
View File
@@ -1400,9 +1400,10 @@ async def admin_list_generation_records(
):
"""List all generation records across all users, with optional filters."""
query = (
select(GenerationRecord, User.username, Project.name)
select(GenerationRecord, User.username, Project.name, Project.industry, IndustryConfig.label)
.join(User, GenerationRecord.user_id == User.id)
.join(Project, GenerationRecord.project_id == Project.id)
.outerjoin(IndustryConfig, Project.industry == IndustryConfig.key)
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
.order_by(GenerationRecord.created_at.desc())
)
@@ -1427,7 +1428,7 @@ async def admin_list_generation_records(
rows = result.all()
items = []
for record, username, project_name in rows:
for record, username, project_name, industry, industry_label in rows:
refs = None
if record.media_references:
try:
@@ -1440,6 +1441,7 @@ async def admin_list_generation_records(
"username": username,
"project_id": record.project_id,
"project_name": project_name,
"industry": industry_label or industry,
"original_prompt": record.original_prompt,
"optimized_prompt": record.optimized_prompt,
"duration": record.duration,
+20 -10
View File
@@ -266,8 +266,11 @@ async def list_tasks(
summary="获取AI生成历史日期分组",
description=(
"按生成完成日期倒序返回当前用户的AI生成历史记录。"
"默认查询 chat_generation_tasks / ChatGenerationTask 新任务历史。"
"history_source=generation_record查询 generation_records / GenerationRecord 旧历史。"
"默认查询 chat_generation_tasks / ChatGenerationTask 的 AI创作历史。"
"history_source=chat_task 时查询 AI创作;"
"history_source=hot_opening_replicate 时查询爆款开头复刻生成素材;"
"history_source=shot_replicate 时查询拆镜复刻生成素材;"
"history_source=generation_record 时查询 generation_records / GenerationRecord 旧项目生成历史。"
"该接口只返回生成成功的任务,即 status=completed 且 generated_at 不为空的数据。"
"必须通过 gen_type 区分图片和视频。"
"分页对象是生成日期,不是单条记录。"
@@ -309,10 +312,12 @@ async def list_history_grouped_days(
history_source: str | None = Query(
None,
description=(
"历史数据来源。默认不传或传 chat_task 查询 chat_generation_tasks / ChatGenerationTask"
"传 generation_record 查询 generation_records / GenerationRecord 旧历史数据"
"历史数据来源。默认不传或传 chat_task 查询 AI创作"
"传 generation_record 查询旧项目生成;"
"传 hot_opening_replicate 查询爆款开头复刻素材;"
"传 shot_replicate 查询拆镜复刻素材"
),
examples=["generation_record"],
examples=["shot_replicate"],
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
@@ -333,8 +338,11 @@ async def list_history_grouped_days(
summary="获取指定日期下的AI生成历史分页",
description=(
"获取某一个生成日期下的生成成功记录分页。"
"默认查询 chat_generation_tasks / ChatGenerationTask 新任务历史。"
"history_source=generation_record查询 generation_records / GenerationRecord 旧历史。"
"默认查询 chat_generation_tasks / ChatGenerationTask 的 AI创作历史。"
"history_source=chat_task 时查询 AI创作;"
"history_source=hot_opening_replicate 时查询爆款开头复刻生成素材;"
"history_source=shot_replicate 时查询拆镜复刻生成素材;"
"history_source=generation_record 时查询 generation_records / GenerationRecord 旧项目生成历史。"
"该接口用于前端在历史分组列表中继续加载某一天的后续记录。"
"例如 /history 接口中某一天 total=18,但 items 只返回前10条,"
"则前端可以调用本接口 page=2&page_size=10 获取该日期下剩余记录。"
@@ -380,10 +388,12 @@ async def list_history_day_items(
history_source: str | None = Query(
None,
description=(
"历史数据来源。默认不传或传 chat_task 查询 chat_generation_tasks / ChatGenerationTask"
"传 generation_record 查询 generation_records / GenerationRecord 旧历史数据"
"历史数据来源。默认不传或传 chat_task 查询 AI创作"
"传 generation_record 查询旧项目生成;"
"传 hot_opening_replicate 查询爆款开头复刻素材;"
"传 shot_replicate 查询拆镜复刻素材"
),
examples=["generation_record"],
examples=["hot_opening_replicate"],
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
@@ -44,5 +44,6 @@ async def list_active_engines(
"supported_models": models,
"supported_sizes": sizes,
"default_size": e.default_size,
"max_image_count": e.max_image_count,
})
return {"items": items}
@@ -112,7 +112,7 @@ async def sync_consumption(
)
async def get_consumption_fields() -> Any | dict:
fields = [
{"field": "id", "description": "主键"},
{"field": "id", "description": "编号"},
{"field": "advertiser_id", "description": "广告主id"},
{"field": "material_id", "description": "素材id"},
{"field": "consume_date", "description": "消耗日期"},
+106 -5
View File
@@ -1,11 +1,18 @@
import logging
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, Query, Body
from app.dependencies import get_db
from app.schemas.resources_material import ResourcesMaterialListResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.resources_material import ResourcesMaterial
from app.models.pre_test_template import PreTestTemplate
from app.dependencies import get_db, get_current_user
from app.schemas.resources_material import ResourcesMaterialListResponse, PreTestMaterialRequest
from app.services.resources_material_service import get_resources_material_list
from app.services.pre_test_queue import pre_test_queue
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/resources-material", tags=["resources-material"])
@@ -25,6 +32,7 @@ async def get_resources_material_list_api(
page: int = Query(1, description="页码"),
page_size: int = Query(20, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> Any | dict:
items, total = await get_resources_material_list(
db=db,
@@ -35,6 +43,7 @@ async def get_resources_material_list_api(
resource_type=resource_type,
page=page,
page_size=page_size,
user_id=current_user.id,
)
return {
@@ -42,4 +51,96 @@ async def get_resources_material_list_api(
"message": "查询成功",
"data": items,
"total": total,
}
}
#如果素材上传的时候没有指定前测,现在可以对已经上传好的素材进行前测
@router.post(
"/pre-commit",
summary="素材列表提交前测",
description="通过素材列表提交未前测的素材,异步处理,立即返回任务ID,结果稍后通过列表查询",
)
async def pre_test_material(
req: PreTestMaterialRequest = Body(..., description="前测请求体"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
) -> Any | dict:
try:
invalid_ids = []
grouped_videos = {}
#检查前测模板是否有效
pre_test_template = await db.execute(
select(PreTestTemplate)
.where(
PreTestTemplate.id == req.pre_test_template_id,
PreTestTemplate.user_id == current_user.id,
PreTestTemplate.deleted_at.is_(None),
)
)
pre_test_template = pre_test_template.scalar_one_or_none()
if not pre_test_template:
return {"code": 1, "message": f"前测模板{req.pre_test_template_id}不存在或不属于当前用户"}
for resource_material_id in req.resources_material_ids:
resource_material = await db.execute(
select(ResourcesMaterial)
.where(
ResourcesMaterial.id == resource_material_id,
ResourcesMaterial.user_id == current_user.id,
ResourcesMaterial.deleted_at.is_(None),
)
)
resource_material = resource_material.scalar_one_or_none()
if not resource_material:
invalid_ids.append(f"{resource_material_id}: 资源不存在或不属于当前用户")
continue
if resource_material.status is not None:
invalid_ids.append(f"{resource_material_id}: 该资源已进行过前测")
continue
if not resource_material.upload_id:
invalid_ids.append(f"{resource_material_id}: 该资源未上传到平台")
continue
if resource_material.resource_type != "video":
invalid_ids.append(f"{resource_material_id}: 前测仅支持视频类型")
continue
key = f"{resource_material.oauth_id}|{resource_material.advertiser_id}"
if key not in grouped_videos:
grouped_videos[key] = []
grouped_videos[key].append({
"id": resource_material.id,
"upload_id": resource_material.upload_id,
})
if invalid_ids:
return {"code": 1, "message": "; ".join(invalid_ids)}
if not grouped_videos:
return {"code": 1, "message": "没有有效的视频资源"}
task_id = generate_id()
await pre_test_queue.enqueue({
"task_id": task_id,
"grouped_videos": grouped_videos,
"pre_test_template_id": req.pre_test_template_id,
})
return {
"code": 0,
"message": "任务已提交,正在处理中",
"data": {
"task_id": task_id,
"total_groups": len(grouped_videos),
},
}
except ValueError as e:
return {"code": 1, "message": str(e)}
except Exception as e:
return {"code": 1, "message": str(e)}
+11 -37
View File
@@ -10,6 +10,8 @@ from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select, func
from app.models.generated_resource import GeneratedResource
from app.dependencies import get_current_user, get_db
from app.models.user import User
@@ -355,51 +357,25 @@ async def batch_update_filename(
.where(GeneratedResource.file_name.is_not(None))
)
result = await db.execute(query)
db_existing_names = set(row[0] for row in result.all())
name_counters = {}
existing_names = set(row[0] for row in result.all())
for item in valid_items:
file_name = item["file_name"]
resource = item["resource"]
base_name, ext = os.path.splitext(file_name)
existing_names = db_existing_names.copy()
if resource.file_name and resource.file_name in existing_names:
existing_names.remove(resource.file_name)
if file_name not in name_counters:
counter = 1
new_file_name = file_name
while new_file_name in existing_names:
new_file_name = f"{base_name}{counter}{ext}"
counter += 1
name_counters[file_name] = {
"base_name": base_name,
"ext": ext,
"counter": counter,
}
existing_names.add(new_file_name)
db_existing_names.add(new_file_name)
else:
counter = name_counters[file_name]["counter"]
base_name = name_counters[file_name]["base_name"]
ext = name_counters[file_name]["ext"]
new_file_name = f"{base_name}{counter}{ext}"
while new_file_name in existing_names:
counter += 1
new_file_name = f"{base_name}{counter}{ext}"
name_counters[file_name]["counter"] = counter + 1
existing_names.add(new_file_name)
db_existing_names.add(new_file_name)
counter = 1
new_file_name = file_name
while new_file_name in existing_names:
new_file_name = f"{base_name}_{counter}{ext}"
counter += 1
item["resource"].file_name = new_file_name
db.add(item["resource"])
existing_names.add(new_file_name)
results.append({
"source_id": item["source_id"],
@@ -421,7 +397,7 @@ async def batch_update_filename(
}
except Exception as e:
return {
"code": 0,
"code": 1,
"message": f"批量修改文件名失败:{str(e)}",
"success_count": 0,
"fail_count": 0,
@@ -461,5 +437,3 @@ async def get_upload_history(
"code": 0,
"message": f"查询上传任务历史失败:{str(e)}",
}
@@ -49,5 +49,7 @@ async def list_active_engines(
"supported_ratios": ratios,
"supported_resolutions": resolutions,
"supported_durations": durations,
"max_image_count": e.max_image_count,
"max_video_count": e.max_video_count,
})
return {"items": items}
+1
View File
@@ -7,6 +7,7 @@ from app.enums.user import *
from app.enums.credit_record import *
from app.enums.token_usage import *
from app.enums.generation_task import *
from app.enums.generation_history import *
from app.enums.generation_status import *
from app.enums.sms import *
from app.enums.notification import *
@@ -0,0 +1,120 @@
from __future__ import annotations
from enum import Enum
from app.enums.generation_task import ChatGenerationTaskStatus, GenerationMode, GenerationType
class GenerationHistorySourceEnum(str, Enum):
"""素材云历史接口支持的数据来源枚举。"""
CHAT_TASK = "chat_task"
GENERATION_RECORD = "generation_record"
HOT_OPENING_REPLICATE = "hot_opening_replicate"
SHOT_REPLICATE = "shot_replicate"
class GenerationHistoryResourceTypeEnum(str, Enum):
"""素材云历史接口支持的资源类型枚举。"""
IMAGE = GenerationType.IMAGE.value
VIDEO = GenerationType.VIDEO.value
GENERATION_HISTORY_SOURCE_LABELS: dict[GenerationHistorySourceEnum, str] = {
GenerationHistorySourceEnum.CHAT_TASK: "AI创作",
GenerationHistorySourceEnum.GENERATION_RECORD: "项目生成",
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE: "爆款开头复刻",
GenerationHistorySourceEnum.SHOT_REPLICATE: "拆镜复刻",
}
"""素材云历史来源中文名称。"""
GENERATION_HISTORY_CHAT_TASK_SOURCES: tuple[GenerationHistorySourceEnum, ...] = (
GenerationHistorySourceEnum.CHAT_TASK,
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE,
GenerationHistorySourceEnum.SHOT_REPLICATE,
)
"""来自 chat_generation_tasks 表的历史来源集合。"""
GENERATION_HISTORY_MODULE_SOURCES: tuple[GenerationHistorySourceEnum, ...] = (
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE,
GenerationHistorySourceEnum.SHOT_REPLICATE,
)
"""需要回填 module_generation_projects/module_generation_steps 的模块来源集合。"""
GENERATION_HISTORY_SOURCE_TO_TASK_MODE: dict[GenerationHistorySourceEnum, GenerationMode] = {
GenerationHistorySourceEnum.CHAT_TASK: GenerationMode.CHATAPI_ASYNC,
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE,
GenerationHistorySourceEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE,
}
"""history_source 到 ChatGenerationTask.generation_mode 的映射。"""
GENERATION_HISTORY_TASK_MODE_VALUE_TO_SOURCE: dict[str, GenerationHistorySourceEnum] = {
task_mode.value: history_source
for history_source, task_mode in GENERATION_HISTORY_SOURCE_TO_TASK_MODE.items()
}
"""ChatGenerationTask.generation_mode 字符串值到 history_source 的映射。"""
GENERATION_HISTORY_COMPLETED_STATUS = ChatGenerationTaskStatus.COMPLETED.value
"""素材云历史只展示生成成功的数据。"""
GENERATION_HISTORY_SOURCE_ALIASES: dict[str, GenerationHistorySourceEnum] = {
"": GenerationHistorySourceEnum.CHAT_TASK,
"chat": GenerationHistorySourceEnum.CHAT_TASK,
"chat_ai": GenerationHistorySourceEnum.CHAT_TASK,
"chat_task": GenerationHistorySourceEnum.CHAT_TASK,
"chat_generation_task": GenerationHistorySourceEnum.CHAT_TASK,
"chat_generation_tasks": GenerationHistorySourceEnum.CHAT_TASK,
"record": GenerationHistorySourceEnum.GENERATION_RECORD,
"records": GenerationHistorySourceEnum.GENERATION_RECORD,
"project": GenerationHistorySourceEnum.GENERATION_RECORD,
"generation_record": GenerationHistorySourceEnum.GENERATION_RECORD,
"generation_records": GenerationHistorySourceEnum.GENERATION_RECORD,
"hot_opening": GenerationHistorySourceEnum.HOT_OPENING_REPLICATE,
"hot_opening_replicate": GenerationHistorySourceEnum.HOT_OPENING_REPLICATE,
"shot": GenerationHistorySourceEnum.SHOT_REPLICATE,
"shot_replicate": GenerationHistorySourceEnum.SHOT_REPLICATE,
}
"""history_source 兼容别名映射。"""
def normalize_generation_history_source(value: str | None) -> GenerationHistorySourceEnum:
"""归一化素材云历史来源。"""
key = (value or "chat_task").lower().strip()
if key in GENERATION_HISTORY_SOURCE_ALIASES:
return GENERATION_HISTORY_SOURCE_ALIASES[key]
return GenerationHistorySourceEnum(key)
def get_generation_history_source_label(source: GenerationHistorySourceEnum | str | None) -> str | None:
"""获取素材云历史来源中文名称。"""
if source is None:
return None
source_enum = source if isinstance(source, GenerationHistorySourceEnum) else GenerationHistorySourceEnum(str(source))
return GENERATION_HISTORY_SOURCE_LABELS.get(source_enum)
def get_generation_history_task_mode(source: GenerationHistorySourceEnum) -> GenerationMode | None:
"""获取 history_source 对应的 ChatGenerationTask.generation_mode。"""
return GENERATION_HISTORY_SOURCE_TO_TASK_MODE.get(source)
def is_generation_history_chat_task_source(source: GenerationHistorySourceEnum) -> bool:
"""判断当前来源是否走 chat_generation_tasks 表。"""
return source in GENERATION_HISTORY_CHAT_TASK_SOURCES
def is_generation_history_module_source(source: GenerationHistorySourceEnum) -> bool:
"""判断当前来源是否需要回填模块项目信息。"""
return source in GENERATION_HISTORY_MODULE_SOURCES
+6
View File
@@ -80,6 +80,10 @@ async def lifespan(app: FastAPI):
from app.tasks.pre_test_result_task import poll_pre_test_results
pre_test_poll_task = asyncio.create_task(poll_pre_test_results())
# 启动前测任务队列
from app.services.pre_test_queue import pre_test_queue
pre_test_queue_task = asyncio.create_task(pre_test_queue.run())
# 启动时立即同步一次未支付订单
asyncio.create_task(asyncio.sleep(5)) # 等待5秒后再同步,让系统完全启动
async def startup_sync():
@@ -104,6 +108,8 @@ async def lifespan(app: FastAPI):
await queue_task
upload_queue.stop()
await upload_queue_task
pre_test_queue.stop()
await pre_test_queue_task
material_consumption_queue.stop()
await consumption_queue_task
consumption_schedule_task.cancel()
+1
View File
@@ -17,6 +17,7 @@ class ImageEngine(Base, TimestampMixin):
# {"2K":{"1:1":"2048×2048",...}, "4K":{"1:1":"4096×4096",...}}
supported_sizes: Mapped[str] = mapped_column(Text, default='{}')
default_size: Mapped[str] = mapped_column(String(32), default="2K")
max_image_count: Mapped[int] = mapped_column(Integer, default=0)
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=0)
+2
View File
@@ -17,6 +17,8 @@ class VideoEngine(Base, TimestampMixin):
supported_resolutions: Mapped[str] = mapped_column(String(256), default='["480p","720p","1080p"]')
supported_durations: Mapped[str] = mapped_column(String(256), nullable=True, default='[4,5,6,7,8,9,10,11,12,13,14,15]')
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)
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)
+48 -2
View File
@@ -168,6 +168,7 @@ class GenerationAIImageEngineOptionOut(BaseModel):
)
default_size: str | None = Field(None, description="默认图片分辨率档位,例如 2K")
priority: int = Field(0, description="引擎优先级,数值越大越优先")
max_image_count: int = Field(0, description="最大图片数量")
class GenerationAIVideoEngineOptionOut(BaseModel):
@@ -182,6 +183,8 @@ class GenerationAIVideoEngineOptionOut(BaseModel):
supported_durations: list[int] = Field(default_factory=list, description="支持的视频时长列表,单位秒")
max_duration: int | None = Field(None, description="最大视频时长,单位秒")
priority: int = Field(0, description="引擎优先级,数值越大越优先")
max_image_count: int | None = Field(None, description="最大图片数量")
max_video_count: int | None = Field(None, description="最大视频数量")
class GenerationAIEngineGroupOut(BaseModel):
@@ -219,6 +222,7 @@ class GenerationAIEngineOptionsOut(BaseModel):
},
"default_size": "2K",
"priority": 10,
"max_image_count": 0,
}
],
"video": [
@@ -232,6 +236,8 @@ class GenerationAIEngineOptionsOut(BaseModel):
"supported_durations": [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"max_duration": 15,
"priority": 10,
"max_image_count": 2,
"max_video_count": 0,
}
],
}
@@ -322,6 +328,26 @@ class GenerationAITaskOut(BaseModel):
description="关联的生成资源账本ID,来源于 generated_resources.id;历史脏数据可能为空",
)
file_name: str | None = Field(None, description="文件名,来源于 generated_resources.file_name")
history_source: str | None = Field(
None,
description=(
"素材云历史来源:chat_task=AI创作,generation_record=项目生成,"
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻"
),
)
history_source_label: str | None = Field(None, description="素材云历史来源中文名称")
module_project_id: str | None = Field(None, description="模块生成项目ID;非模块生成历史返回 null")
module_project_title: str | None = Field(None, description="模块生成项目标题;非模块生成历史返回 null")
module_step_id: str | None = Field(None, description="模块生成步骤ID;非模块生成历史返回 null")
module_step_code: str | None = Field(None, description="模块生成步骤编码;非模块生成历史返回 null")
hot_opening_project_id: str | None = Field(None, description="爆款开头复刻项目ID;非爆款开头复刻返回 null")
hot_opening_project_title: str | None = Field(None, description="爆款开头复刻项目标题;非爆款开头复刻返回 null")
shot_replicate_project_id: str | None = Field(None, description="拆镜复刻项目ID;非拆镜复刻返回 null")
shot_replicate_project_title: str | None = Field(None, description="拆镜复刻项目标题;非拆镜复刻返回 null")
shot_task_set_id: str | None = Field(None, description="拆镜复刻总任务ID;非拆镜复刻返回 null")
shot_segment_id: str | None = Field(None, description="拆镜复刻片段ID;非拆镜复刻返回 null")
shot_segment_index: int | None = Field(None, description="拆镜复刻片段序号;非拆镜复刻返回 null")
shot_segment_label: str | None = Field(None, description="拆镜复刻片段展示名称,例如:拆镜复刻片段1;非拆镜复刻返回 null")
gen_type: str = Field(..., description="生成类型:image=图片,video=视频")
generation_mode: str | None = Field(
None,
@@ -538,6 +564,26 @@ class GenerationAIRecordHistoryItemOut(BaseModel):
description="关联的生成资源账本ID,来源于 generated_resources.id;历史脏数据可能为空",
)
file_name: str | None = Field(None, description="文件名,来源于 generated_resources.file_name")
history_source: str | None = Field(
None,
description=(
"素材云历史来源:chat_task=AI创作,generation_record=项目生成,"
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻"
),
)
history_source_label: str | None = Field(None, description="素材云历史来源中文名称")
module_project_id: str | None = Field(None, description="模块生成项目ID;非模块生成历史返回 null")
module_project_title: str | None = Field(None, description="模块生成项目标题;非模块生成历史返回 null")
module_step_id: str | None = Field(None, description="模块生成步骤ID;非模块生成历史返回 null")
module_step_code: str | None = Field(None, description="模块生成步骤编码;非模块生成历史返回 null")
hot_opening_project_id: str | None = Field(None, description="爆款开头复刻项目ID;非爆款开头复刻返回 null")
hot_opening_project_title: str | None = Field(None, description="爆款开头复刻项目标题;非爆款开头复刻返回 null")
shot_replicate_project_id: str | None = Field(None, description="拆镜复刻项目ID;非拆镜复刻返回 null")
shot_replicate_project_title: str | None = Field(None, description="拆镜复刻项目标题;非拆镜复刻返回 null")
shot_task_set_id: str | None = Field(None, description="拆镜复刻总任务ID;非拆镜复刻返回 null")
shot_segment_id: str | None = Field(None, description="拆镜复刻片段ID;非拆镜复刻返回 null")
shot_segment_index: int | None = Field(None, description="拆镜复刻片段序号;非拆镜复刻返回 null")
shot_segment_label: str | None = Field(None, description="拆镜复刻片段展示名称,例如:拆镜复刻片段1;非拆镜复刻返回 null")
gen_type: str = Field(..., description="生成类型:image=图片,video=视频")
generation_mode: str | None = Field(
"generation_record",
@@ -624,7 +670,7 @@ class GenerationAIHistoryDayGroupOut(BaseModel):
default_factory=list,
description=(
"当前生成日期下倒序前10条生成记录详情。"
"默认 history_source=chat_task 时 item 为 GenerationAITaskOut"
"history_source=chat_task/hot_opening_replicate/shot_replicate 时 item 为 GenerationAITaskOut"
"history_source=generation_record 时 item 为 GenerationAIRecordHistoryItemOut"
),
)
@@ -687,7 +733,7 @@ class GenerationAIHistoryDayItemsOut(BaseModel):
default_factory=list,
description=(
"当前日期下的生成记录详情列表,按 generated_at 倒序排列。"
"默认 history_source=chat_task 时 item 为 GenerationAITaskOut"
"history_source=chat_task/hot_opening_replicate/shot_replicate 时 item 为 GenerationAITaskOut"
"history_source=generation_record 时 item 为 GenerationAIRecordHistoryItemOut"
),
)
+7 -2
View File
@@ -260,8 +260,13 @@ class HomeMaterialPublicAssetOut(BaseModel):
class HomeMaterialPublicCategoryGroupOut(BaseModel):
category: HomeMaterialPublicCategoryOut
assets: list[HomeMaterialPublicAssetOut] = Field(default_factory=list)
id: str = Field(..., description="行业ID。")
name: str = Field(..., description="行业名称。")
key: str = Field(..., description="行业key。")
icon: str | None = Field(None, description="前端图标名。")
description: str | None = Field(None, description="行业描述。")
sort_order: int = Field(0, description="排序。")
assets: list[HomeMaterialPublicAssetOut] = Field(default_factory=list, description="行业下素材列表。")
class HomeMaterialPublicGroupedOut(BaseModel):
@@ -12,6 +12,7 @@ class ImageEngineCreate(BaseModel):
supported_models: str = Field(default='["doubao-seedream-5-0-260128"]')
supported_sizes: str = Field(default='{}')
default_size: str = Field(default="2K", max_length=32)
max_image_count: int = Field(default=0)
generate_url: str = Field(default="", max_length=512)
is_active: bool = True
priority: int = 0
@@ -31,6 +32,7 @@ class ImageEnginePublic(BaseModel):
supported_models: list[str] = []
supported_sizes: dict[str, dict[str, str]] = {}
default_size: str = "2K"
max_image_count: int = 0
class ImageEngineListResponse(BaseModel):
@@ -56,4 +56,9 @@ class ResourcesMaterialListResponse(BaseModel):
code: int = Field(0, description="返回码,0表示成功")
message: str = Field("查询成功", description="返回消息")
data: list[ResourcesMaterialOut] = Field(..., description="素材列表数据")
total: int = Field(..., description="总记录数")
total: int = Field(..., description="总记录数")
class PreTestMaterialRequest(BaseModel):
resources_material_ids: list[str] = Field(..., description="资源素材表id列表")
pre_test_template_id: str = Field(..., description="前测模板id")
@@ -13,6 +13,8 @@ class VideoEngineCreate(BaseModel):
supported_resolutions: str = Field(default='["480p","720p","1080p"]')
supported_durations: str = Field(default='[4,5,6,7,8,9,10,11,12,13,14,15]')
max_duration: int = Field(default=15)
max_image_count: int = Field(default=2)
max_video_count: int = Field(default=0)
generate_url: str = Field(default="", max_length=512)
query_url: str = Field(default="", max_length=512)
is_active: bool = True
@@ -33,6 +35,8 @@ class VideoEnginePublic(BaseModel):
supported_ratios: list[str] = []
supported_resolutions: list[str] = []
supported_durations: list[int] = []
max_image_count: int = 2
max_video_count: int = 0
class VideoEngineListResponse(BaseModel):
@@ -15,6 +15,12 @@ from app.models.project import Project
from app.models.image_engine import ImageEngine
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.enums.generation_history import (
GenerationHistorySourceEnum,
get_generation_history_source_label,
get_generation_history_task_mode,
normalize_generation_history_source,
)
from app.schemas.generation_ai import (
GenerationAIEngineGroupOut,
GenerationAIEngineOptionsOut,
@@ -31,11 +37,15 @@ from app.services.generation_billing_service import (
from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK,
SOURCE_MODEL_GENERATION_RECORD,
batch_get_generated_resource_id_map,
batch_get_generated_resource_info_map,
soft_delete_chat_task_resources,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.generation_history_meta_service import (
GenerationHistoryMeta,
batch_load_generation_history_meta_map,
build_empty_history_meta,
)
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.utils.id_gen import generate_id
@@ -176,6 +186,7 @@ async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEng
supported_sizes=_image_supported_sizes(engine),
default_size=engine.default_size,
priority=engine.priority or 0,
max_image_count=engine.max_image_count,
)
for engine in image_result.scalars().all()
]
@@ -190,6 +201,8 @@ async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEng
supported_durations=_parse_list(engine.supported_durations, []),
max_duration=engine.max_duration,
priority=engine.priority or 0,
max_image_count=engine.max_image_count,
max_video_count=engine.max_video_count,
)
for engine in video_result.scalars().all()
]
@@ -329,19 +342,91 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
return task
def _resolve_error_message(error_message: str | None) -> str | None:
"""匹配 ARK_ERRORS 字典,将原始错误码转换为友好提示。
与 app/api/v1/generation.py 的 _record_to_out 保持一致。
注意:celery 任务中已调用 extract_error_message 将错误码转为中文提示后存入数据库,
所以到达此函数的 message 可能是:
1. 已翻译的中文提示(ARK_ERRORS 的 value)→ 直接返回
2. 原始错误字符串(含 code='...' 或 JSON 格式)→ 匹配 ARK_ERRORS
3. 未知内容 → 返回 "生成失败"
"""
if not error_message:
return error_message
from app.services.error_codes import ARK_ERRORS
# 如果已经是 ARK_ERRORS 中已翻译的中文值,直接返回
if error_message in ARK_ERRORS.values():
return error_message
import re
# 匹配以下格式中的错误码:
# 1. {'error': {'code': 'XXX', ...}} — str(error_obj) 的 Python dict 形式
# 2. {"error": {"code": "XXX", ...}} — JSON 形式
# 3. code='XXX' — 旧格式
for pattern in [
r"'code'\s*:\s*'([^']+)'", # 'code': 'XXX'
r'"code"\s*:\s*"([^"]+)"', # "code": "XXX"
r"code='([^']+)'", # code='XXX'
]:
match = re.search(pattern, error_message)
if match:
code = match.group(1)
if code in ARK_ERRORS:
return ARK_ERRORS[code]
# 兜底:按冒号分割,检查第二部分是否是已知错误码
parts = error_message.split(":")
if len(parts) >= 2 and parts[1].strip() in ARK_ERRORS:
return ARK_ERRORS[parts[1].strip()]
# 没有匹配到已知错误码时,直接返回"生成失败"
return "生成失败"
def record_to_out(
task: ChatGenerationTask,
is_admin: bool = False,
generated_resource_id: str | None = None,
file_name: str | None = None,
history_meta: GenerationHistoryMeta | None = None,
) -> GenerationAITaskOut:
refs = _parse_json(task.media_references)
snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json))
source = GenerationHistorySourceEnum.CHAT_TASK
try:
source = GenerationHistorySourceEnum(
"chat_task" if task.generation_mode == "chatapi_async" else str(task.generation_mode or "chat_task")
)
except ValueError:
source = GenerationHistorySourceEnum.CHAT_TASK
meta = history_meta or build_empty_history_meta(source)
return GenerationAITaskOut(
id=task.id,
user_id=task.user_id if is_admin else None,
user_name=getattr(task, "username", None) if is_admin else None,
project_id=None,
generated_resource_id=generated_resource_id,
file_name=file_name,
history_source=meta.get("history_source"),
history_source_label=meta.get("history_source_label"),
module_project_id=meta.get("module_project_id"),
module_project_title=meta.get("module_project_title"),
module_step_id=meta.get("module_step_id"),
module_step_code=meta.get("module_step_code"),
hot_opening_project_id=meta.get("hot_opening_project_id"),
hot_opening_project_title=meta.get("hot_opening_project_title"),
shot_replicate_project_id=meta.get("shot_replicate_project_id"),
shot_replicate_project_title=meta.get("shot_replicate_project_title"),
shot_task_set_id=meta.get("shot_task_set_id"),
shot_segment_id=meta.get("shot_segment_id"),
shot_segment_index=meta.get("shot_segment_index"),
shot_segment_label=meta.get("shot_segment_label"),
gen_type=task.gen_type,
generation_mode=task.generation_mode,
pipeline_stage=task.pipeline_stage,
@@ -370,12 +455,11 @@ def record_to_out(
video_tokens_used=task.video_tokens_used or 0,
retry_count=task.retry_count or 0,
poll_count=task.poll_count or 0,
error_message=task.error_message,
error_message=_resolve_error_message(task.error_message),
created_at=task.created_at,
generated_at=task.generated_at,
)
def engine_snapshot_out(snapshot: dict) -> dict:
"""
从完整的 engine_snapshot 中过滤出需要返回的字段
@@ -460,18 +544,20 @@ def _normalize_history_gen_type(gen_type: str | None) -> str:
def _normalize_history_source(history_source: str | None) -> str:
"""Normalize history source query param.
def _normalize_history_source(history_source: str | None) -> GenerationHistorySourceEnum:
"""Normalize history_source query param.
默认保持原来的 chat_generation_tasks 历史;只有显式传 generation_record
才切换旧 generation_records 历史,避免影响现有前端。
默认保持原来的 chat_generation_tasks / chatapi_async 历史;
显式传 hot_opening_replicate 或 shot_replicate 时查询对应模块素材;
显式传 generation_record 时查询旧 generation_records 历史。
"""
value = (history_source or "chat_task").lower().strip()
if value in ("", "chat", "chat_task", "chat_generation_task", "chat_generation_tasks"):
return "chat_task"
if value in ("record", "records", "generation_record", "generation_records"):
return "generation_record"
raise HTTPException(status_code=400, detail="history_source 仅支持 chat_taskgeneration_record")
try:
return normalize_generation_history_source(history_source)
except ValueError:
raise HTTPException(
status_code=400,
detail="history_source 仅支持 chat_taskgeneration_record、hot_opening_replicate、shot_replicate",
)
def _history_day_to_str(value) -> str:
@@ -489,10 +575,13 @@ def _parse_history_date(value: str) -> date:
raise HTTPException(status_code=400, detail="generated_date 格式必须是 YYYY-MM-DD")
def _history_base_filters(user_id: str, gen_type: str):
def _history_base_filters(user_id: str, gen_type: str, source: GenerationHistorySourceEnum):
task_mode = get_generation_history_task_mode(source)
if not task_mode:
raise HTTPException(status_code=400, detail="history_source 不支持查询 ChatGenerationTask 历史")
return [
ChatGenerationTask.user_id == user_id,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.generation_mode == task_mode.value,
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.status == "completed",
ChatGenerationTask.gen_type == gen_type,
@@ -524,6 +613,20 @@ def generation_record_to_history_out(
project_name=project_name,
generated_resource_id=generated_resource_id,
file_name=file_name,
history_source=GenerationHistorySourceEnum.GENERATION_RECORD.value,
history_source_label=get_generation_history_source_label(GenerationHistorySourceEnum.GENERATION_RECORD),
module_project_id=None,
module_project_title=None,
module_step_id=None,
module_step_code=None,
hot_opening_project_id=None,
hot_opening_project_title=None,
shot_replicate_project_id=None,
shot_replicate_project_title=None,
shot_task_set_id=None,
shot_segment_id=None,
shot_segment_index=None,
shot_segment_label=None,
gen_type=record.gen_type,
generation_mode="generation_record",
pipeline_stage=None,
@@ -553,7 +656,7 @@ def generation_record_to_history_out(
video_tokens_used=record.video_tokens_used or 0,
retry_count=0,
poll_count=0,
error_message=record.error_message,
error_message=_resolve_error_message(record.error_message),
created_at=record.created_at,
generated_at=record.generated_at,
)
@@ -732,9 +835,10 @@ async def list_generation_history_grouped_days(
- 每页最多返回 10 个生成日期
- 每个日期分组内最多返回倒序前 10 条任务
- 只返回 completed 成功任务
- history_source 支持 chat_task / generation_record / hot_opening_replicate / shot_replicate
"""
source = _normalize_history_source(history_source)
if source == "generation_record":
if source == GenerationHistorySourceEnum.GENERATION_RECORD:
return await list_generation_record_history_grouped_days(
db=db,
user_id=user_id,
@@ -747,7 +851,7 @@ async def list_generation_history_grouped_days(
page = max(page, 1)
page_size = min(max(page_size, 1), HISTORY_DAY_PAGE_SIZE_MAX)
filters = _history_base_filters(user_id, gen_type)
filters = _history_base_filters(user_id, gen_type, source)
day_expr = func.date(ChatGenerationTask.generated_at).label("generated_date")
days_subquery = (
@@ -790,12 +894,17 @@ async def list_generation_history_grouped_days(
raw_groups.append((generated_day, day_total, tasks))
all_task_ids.extend(task.id for task in tasks)
resource_id_map = await batch_get_generated_resource_id_map(
resource_info_map = await batch_get_generated_resource_info_map(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=all_task_ids,
resource_type=gen_type,
)
history_meta_map = await batch_load_generation_history_meta_map(
db,
source=source,
chat_task_ids=all_task_ids,
)
groups = [
{
@@ -804,7 +913,9 @@ async def list_generation_history_grouped_days(
"items": [
record_to_out(
task,
generated_resource_id=resource_id_map.get(task.id),
generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"),
file_name=resource_info_map.get(task.id, {}).get("file_name"),
history_meta=history_meta_map.get(task.id),
)
for task in tasks
],
@@ -833,9 +944,10 @@ async def list_generation_history_day_items(
获取指定生成日期下的历史记录分页。
用于前端点击某一天后,继续加载该日期下的第 2 页、第 3 页数据。
history_source 支持 chat_task / generation_record / hot_opening_replicate / shot_replicate。
"""
source = _normalize_history_source(history_source)
if source == "generation_record":
if source == GenerationHistorySourceEnum.GENERATION_RECORD:
return await list_generation_record_history_day_items(
db=db,
user_id=user_id,
@@ -850,7 +962,7 @@ async def list_generation_history_day_items(
page = max(page, 1)
page_size = min(max(page_size, 1), 100)
filters = _history_base_filters(user_id, gen_type)
filters = _history_base_filters(user_id, gen_type, source)
day_expr = func.date(ChatGenerationTask.generated_at)
total = (
@@ -874,12 +986,18 @@ async def list_generation_history_day_items(
)
tasks = list(result.scalars().all())
resource_id_map = await batch_get_generated_resource_id_map(
task_ids = [task.id for task in tasks]
resource_info_map = await batch_get_generated_resource_info_map(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=[task.id for task in tasks],
source_ids=task_ids,
resource_type=gen_type,
)
history_meta_map = await batch_load_generation_history_meta_map(
db,
source=source,
chat_task_ids=task_ids,
)
return {
"generated_date": target_day.strftime("%Y-%m-%d"),
@@ -889,7 +1007,9 @@ async def list_generation_history_day_items(
"items": [
record_to_out(
task,
generated_resource_id=resource_id_map.get(task.id),
generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"),
file_name=resource_info_map.get(task.id, {}).get("file_name"),
history_meta=history_meta_map.get(task.id),
)
for task in tasks
],
@@ -0,0 +1,283 @@
from __future__ import annotations
from typing import Any, TypedDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.generation_history import (
GenerationHistorySourceEnum,
get_generation_history_source_label,
is_generation_history_module_source,
)
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
class GenerationHistoryMeta(TypedDict):
"""素材云历史项模块上下文回填字段。"""
history_source: str | None
history_source_label: str | None
module_project_id: str | None
module_project_title: str | None
module_step_id: str | None
module_step_code: str | None
hot_opening_project_id: str | None
hot_opening_project_title: str | None
shot_replicate_project_id: str | None
shot_replicate_project_title: str | None
shot_task_set_id: str | None
shot_segment_id: str | None
shot_segment_index: int | None
shot_segment_label: str | None
class _StepLinkInfo(TypedDict):
module_project_id: str | None
module_step_id: str | None
module_step_code: str | None
module: str | None
class _ProjectInfo(TypedDict):
module_project_id: str
module_project_title: str | None
module: str | None
class _ShotSegmentInfo(TypedDict):
shot_task_set_id: str | None
shot_segment_id: str | None
shot_segment_index: int | None
shot_segment_label: str | None
def _unique(values: list[str] | tuple[str, ...]) -> list[str]:
return list(dict.fromkeys(str(value) for value in values if value))
def _segment_label(segment_index: int | None) -> str | None:
if segment_index is None:
return None
return f"片段{segment_index}"
def build_empty_history_meta(source: GenerationHistorySourceEnum) -> GenerationHistoryMeta:
"""构造统一历史字段,非对应模块字段保持 null。"""
return {
"history_source": source.value,
"history_source_label": get_generation_history_source_label(source),
"module_project_id": None,
"module_project_title": None,
"module_step_id": None,
"module_step_code": None,
"hot_opening_project_id": None,
"hot_opening_project_title": None,
"shot_replicate_project_id": None,
"shot_replicate_project_title": None,
"shot_task_set_id": None,
"shot_segment_id": None,
"shot_segment_index": None,
"shot_segment_label": None,
}
async def _load_step_link_map(
db: AsyncSession,
*,
chat_task_ids: list[str],
source: GenerationHistorySourceEnum,
) -> dict[str, _StepLinkInfo]:
ids = _unique(chat_task_ids)
if not ids:
return {}
stmt = (
select(
ModuleGenerationStep.chat_task_id.label("chat_task_id"),
ModuleGenerationStep.id.label("module_step_id"),
ModuleGenerationStep.project_id.label("module_project_id"),
ModuleGenerationStep.step_code.label("module_step_code"),
ModuleGenerationStep.module.label("module"),
ModuleGenerationStep.is_current.label("is_current"),
ModuleGenerationStep.updated_at.label("updated_at"),
)
.where(
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.chat_task_id.in_(ids),
ModuleGenerationStep.module == source.value,
)
.order_by(
ModuleGenerationStep.chat_task_id.asc(),
ModuleGenerationStep.is_current.desc(),
ModuleGenerationStep.updated_at.desc(),
)
)
rows = (await db.execute(stmt)).mappings().all()
link_map: dict[str, _StepLinkInfo] = {}
for row in rows:
chat_task_id = row["chat_task_id"]
if not chat_task_id or chat_task_id in link_map:
continue
link_map[chat_task_id] = {
"module_project_id": row["module_project_id"],
"module_step_id": row["module_step_id"],
"module_step_code": row["module_step_code"],
"module": row["module"],
}
return link_map
async def _load_project_map(
db: AsyncSession,
*,
module_project_ids: list[str],
) -> dict[str, _ProjectInfo]:
ids = _unique(module_project_ids)
if not ids:
return {}
stmt = (
select(
ModuleGenerationProject.id.label("module_project_id"),
ModuleGenerationProject.title.label("module_project_title"),
ModuleGenerationProject.module.label("module"),
)
.where(
ModuleGenerationProject.deleted_at.is_(None),
ModuleGenerationProject.id.in_(ids),
)
)
return {
row["module_project_id"]: {
"module_project_id": row["module_project_id"],
"module_project_title": row["module_project_title"],
"module": row["module"],
}
for row in (await db.execute(stmt)).mappings().all()
if row["module_project_id"]
}
async def _load_shot_segment_map(
db: AsyncSession,
*,
module_project_ids: list[str],
) -> dict[str, _ShotSegmentInfo]:
ids = _unique(module_project_ids)
if not ids:
return {}
stmt = (
select(
ShotReplicateSegment.module_project_id.label("module_project_id"),
ShotReplicateSegment.id.label("shot_segment_id"),
ShotReplicateSegment.task_set_id.label("shot_task_set_id"),
ShotReplicateSegment.segment_index.label("shot_segment_index"),
ShotReplicateSegment.updated_at.label("updated_at"),
)
.where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.module_project_id.in_(ids),
)
.order_by(
ShotReplicateSegment.module_project_id.asc(),
ShotReplicateSegment.updated_at.desc(),
)
)
segment_map: dict[str, _ShotSegmentInfo] = {}
rows = (await db.execute(stmt)).mappings().all()
for row in rows:
module_project_id = row["module_project_id"]
if not module_project_id or module_project_id in segment_map:
continue
segment_index = row["shot_segment_index"]
segment_map[module_project_id] = {
"shot_task_set_id": row["shot_task_set_id"],
"shot_segment_id": row["shot_segment_id"],
"shot_segment_index": segment_index,
"shot_segment_label": _segment_label(segment_index),
}
return segment_map
def _merge_meta(
*,
source: GenerationHistorySourceEnum,
step_info: _StepLinkInfo | None,
project_info: _ProjectInfo | None,
shot_info: _ShotSegmentInfo | None,
) -> GenerationHistoryMeta:
meta = build_empty_history_meta(source)
module_project_id = step_info["module_project_id"] if step_info else None
module_step_id = step_info["module_step_id"] if step_info else None
module_step_code = step_info["module_step_code"] if step_info else None
module_project_title = project_info["module_project_title"] if project_info else None
meta["module_project_id"] = module_project_id
meta["module_project_title"] = module_project_title
meta["module_step_id"] = module_step_id
meta["module_step_code"] = module_step_code
if source == GenerationHistorySourceEnum.HOT_OPENING_REPLICATE:
meta["hot_opening_project_id"] = module_project_id
meta["hot_opening_project_title"] = module_project_title
elif source == GenerationHistorySourceEnum.SHOT_REPLICATE:
meta["shot_replicate_project_id"] = module_project_id
meta["shot_replicate_project_title"] = module_project_title
if shot_info:
meta["shot_task_set_id"] = shot_info["shot_task_set_id"]
meta["shot_segment_id"] = shot_info["shot_segment_id"]
meta["shot_segment_index"] = shot_info["shot_segment_index"]
meta["shot_segment_label"] = shot_info["shot_segment_label"]
return meta
async def batch_load_generation_history_meta_map(
db: AsyncSession,
*,
source: GenerationHistorySourceEnum,
chat_task_ids: list[str],
) -> dict[str, GenerationHistoryMeta]:
"""批量回填历史列表模块上下文,避免逐条链式查询。"""
ids = _unique(chat_task_ids)
if not ids:
return {}
if not is_generation_history_module_source(source):
return {chat_task_id: build_empty_history_meta(source) for chat_task_id in ids}
step_link_map = await _load_step_link_map(db, chat_task_ids=ids, source=source)
module_project_ids = [
step_info["module_project_id"]
for step_info in step_link_map.values()
if step_info.get("module_project_id")
]
project_map = await _load_project_map(db, module_project_ids=module_project_ids)
shot_segment_map: dict[str, _ShotSegmentInfo] = {}
if source == GenerationHistorySourceEnum.SHOT_REPLICATE:
shot_segment_map = await _load_shot_segment_map(db, module_project_ids=module_project_ids)
meta_map: dict[str, GenerationHistoryMeta] = {}
for chat_task_id in ids:
step_info = step_link_map.get(chat_task_id)
module_project_id = step_info.get("module_project_id") if step_info else None
project_info = project_map.get(module_project_id) if module_project_id else None
shot_info = shot_segment_map.get(module_project_id) if module_project_id else None
meta_map[chat_task_id] = _merge_meta(
source=source,
step_info=step_info,
project_info=project_info,
shot_info=shot_info,
)
return meta_map
@@ -0,0 +1,246 @@
import asyncio
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.base import async_session
from app.models.resources_material import ResourcesMaterial
from app.models.user_oauth import UserOAuth
from app.models.pre_test_template import PreTestTemplate
from app.utils.logger import get_logger
from app.utils.douyinApi import DouyinApi
import json
logger = get_logger("pre_test_queue", "pre_test_queue")
douyin_api = DouyinApi()
class PreTestQueue:
def __init__(self):
self.queue: asyncio.Queue[dict] = asyncio.Queue()
self.running = False
async def enqueue(self, task_data: dict):
"""Add a pre-test task to the queue."""
await self.queue.put(task_data)
logger.info(f"Enqueued pre-test task: {task_data.get('task_id')}")
async def run(self):
"""Main processing loop."""
self.running = True
logger.info("Pre-test queue started")
while self.running:
try:
task_data = await asyncio.wait_for(self.queue.get(), timeout=5.0)
except asyncio.TimeoutError:
continue
try:
await self._process(task_data)
except Exception as e:
logger.error(f"Error processing pre-test task: {e}", exc_info=True)
finally:
self.queue.task_done()
logger.info("Pre-test queue stopped")
async def _process(self, task_data: dict):
"""Process a single pre-test task."""
task_id = task_data.get("task_id")
grouped_videos = task_data.get("grouped_videos", {})
pre_test_template_id = task_data.get("pre_test_template_id")
logger.info(f"Processing pre-test task: {task_id}")
for key, videos in grouped_videos.items():
oauth_id, advertiser_id = key.split("|")
video_ids = [v["upload_id"] for v in videos]
logger.info(f"Processing {len(video_ids)} videos for oauth_id={oauth_id}, advertiser_id={advertiser_id}")
try:
async with async_session() as db:
await _pre_test_material_batch(
oauth_id=oauth_id,
advertiser_id=advertiser_id,
video_ids=video_ids,
pre_test_template_id=pre_test_template_id,
db=db,
resource_material_ids=[v["id"] for v in videos],
)
except Exception as e:
logger.error(f"Failed to process pre-test for oauth_id={oauth_id}, advertiser_id={advertiser_id}: {e}", exc_info=True)
def stop(self):
"""Stop the queue."""
self.running = False
async def _pre_test_material_batch(
oauth_id: str,
advertiser_id: str,
video_ids: list[str],
pre_test_template_id: str,
db: AsyncSession,
resource_material_ids: list[str],
) -> any:
oauth = await db.execute(
select(UserOAuth).where(
UserOAuth.id == oauth_id,
UserOAuth.deleted_at.is_(None),
)
)
oauth = oauth.scalar_one_or_none()
if not oauth:
note = "授权记录不存在"
for video_id, resource_material_id in zip(video_ids, resource_material_ids):
await _update_material_pre_test_status(
db, resource_material_id,
task_id=None,
status="FAILED",
note=note,
pre_result=None,
pre_test_template_id=pre_test_template_id,
)
await db.commit()
return {"code": -1, "message": note, "data": {}}
pre_test_template = await db.execute(
select(PreTestTemplate).where(
PreTestTemplate.id == pre_test_template_id,
PreTestTemplate.deleted_at.is_(None),
PreTestTemplate.user_id == oauth.user_id,
)
)
pre_test_template = pre_test_template.scalar_one_or_none()
if not pre_test_template:
note = f"前测模板 {pre_test_template_id} 不存在"
for video_id, resource_material_id in zip(video_ids, resource_material_ids):
await _update_material_pre_test_status(
db, resource_material_id,
task_id=None,
status="FAILED",
note=note,
pre_result=None,
pre_test_template_id=pre_test_template_id,
)
await db.commit()
return {"code": -1, "message": note, "data": {}}
diagnose_config = {}
if pre_test_template.platform:
diagnose_config["platform"] = pre_test_template.platform
if pre_test_template.external_action:
diagnose_config["external_action"] = pre_test_template.external_action
if pre_test_template.cpa_bid:
diagnose_config["cpa_bid"] = pre_test_template.cpa_bid
if pre_test_template.audience_gender:
diagnose_config["audience_gender"] = pre_test_template.audience_gender
if pre_test_template.audience_age:
diagnose_config["audience_age"] = json.loads(pre_test_template.audience_age)
if pre_test_template.audience_region:
diagnose_config["audience_region"] = json.loads(pre_test_template.audience_region)
if pre_test_template.audience_network:
diagnose_config["audience_network"] = json.loads(pre_test_template.audience_network)
if pre_test_template.cus_name:
diagnose_config["cus_name"] = pre_test_template.cus_name
if pre_test_template.pricing_type:
diagnose_config["pricing_type"] = pre_test_template.pricing_type
if pre_test_template.cost_cap:
diagnose_config["cost_cap"] = pre_test_template.cost_cap
if pre_test_template.target_cost:
diagnose_config["target_cost"] = pre_test_template.target_cost
if pre_test_template.nobid:
diagnose_config["nobid"] = pre_test_template.nobid
if pre_test_template.cpc_bid:
diagnose_config["cpc_bid"] = pre_test_template.cpc_bid
if pre_test_template.budget:
diagnose_config["budget"] = pre_test_template.budget
params = {
"advertiser_id": int(advertiser_id),
"video_ids": video_ids,
"diagnose_config": diagnose_config,
}
response = await douyin_api.pre_test_material(oauth_id, params)
code = response.get("code", -1)
if code != 0:
#记录日志
logger.error(f"前测提交失败, oauth_id={oauth_id}, advertiser_id={advertiser_id}: {json.dumps(response)}")
note = response.get("message", "未知错误")
for video_id, resource_material_id in zip(video_ids, resource_material_ids):
await _update_material_pre_test_status(
db, resource_material_id,
task_id=None,
status="FAILED",
note=note,
pre_result=None,
pre_test_template_id=pre_test_template_id,
)
await db.commit()
return response
data = response.get("data", {})
task_ids = data.get("task_ids", [])
fail_video_ids = data.get("fail_video_ids", {})
success_count = 0
for i, (video_id, resource_material_id) in enumerate(zip(video_ids, resource_material_ids)):
if video_id in fail_video_ids:
fail_info = fail_video_ids[video_id]
err_code = fail_info.get("err_code", "")
err_message = fail_info.get("err_message", "未知错误")
note = f"失败[{err_code}]: {err_message}"
await _update_material_pre_test_status(
db, resource_material_id,
task_id=None,
status="FAILED",
note=note,
pre_result=None,
pre_test_template_id=pre_test_template_id,
)
else:
task_id = str(task_ids[success_count]) if success_count < len(task_ids) else None
await _update_material_pre_test_status(
db, resource_material_id,
task_id=task_id,
status="PENDING",
note="",
pre_result=None,
pre_test_template_id=pre_test_template_id,
)
success_count += 1
await db.commit()
return response
async def _update_material_pre_test_status(
db: AsyncSession,
resource_material_id: str,
task_id: str | None,
status: str,
note: str,
pre_result: str | None,
pre_test_template_id: str | None,
):
await db.execute(
update(ResourcesMaterial).where(
ResourcesMaterial.id == resource_material_id,
ResourcesMaterial.deleted_at.is_(None),
).values(
task_id=task_id,
status=status,
note=note,
pre_result=pre_result,
pre_test_template_id=pre_test_template_id,
)
)
pre_test_queue = PreTestQueue()
@@ -18,11 +18,14 @@ async def get_resources_material_list(
resource_type: Optional[str] = None,
page: int = 1,
page_size: int = 20,
user_id: Optional[str] = None,
) -> Tuple[list[ResourcesMaterialOut], int]:
resource_alias = aliased(GeneratedResource)
query = select(ResourcesMaterial).where(ResourcesMaterial.deleted_at.is_(None))
if user_id:
query = query.where(ResourcesMaterial.user_id == user_id)
if advertiser_id:
query = query.where(ResourcesMaterial.advertiser_id == advertiser_id)
if material_id:
+4 -3
View File
@@ -122,7 +122,7 @@ class UploadQueue:
code = -1
if code != 0:
logger.error(f"Error getting account info: {json.dumps(account_info)}")
logger.error(f"获取账户信息失败: {json.dumps(account_info)}")
else:
existing_account = await db.execute(
select(UserOAuthAccount).where(
@@ -161,8 +161,7 @@ class UploadQueue:
)
await db.commit()
message = result.get('message') or result.get('error', 'Unknown error')
logger.info(f"Upload task {task_id} completed: {'success' if result.get('success') else 'failed'}. Message: {message}")
logger.info(f"上传任务{task_id}完成: {'success' if result.get('success') else 'failed'}. 上传结果: {json.dumps(result, ensure_ascii=False)}")
except Exception as e:
async with async_session() as db:
@@ -323,6 +322,7 @@ async def _upload_to_juliang(
response = await douyin_api.upload_image_material(oauth_id, data, files)
if response["code"] != 0:
logger.error(f"上传图片素材失败: {json.dumps(response, ensure_ascii=False)}")
return {
"resource_id": resource.id,
"advertiser_id": advertiser_id,
@@ -409,6 +409,7 @@ async def _upload_to_juliang(
response = await douyin_api.upload_video_material(oauth_id, data, files)
if response["code"] != 0:
logger.error(f"上传视频素材失败: {json.dumps(response, ensure_ascii=False)}")
return {
"resource_id": resource.id,
"advertiser_id": advertiser_id,
@@ -0,0 +1,30 @@
# Debug Session: ratio-options-not-showing
## Session ID
ratio-options-not-showing
## Created
2026-07-01
## Symptom
用户反馈:GenerateConver.tsx 中比例选项(ratioOptions)不显示,控制台无报错。
## Hypotheses (待验证假设)
1. **H1**: `ratioOptions` 默认值未生效 - useState 初始化失败
2. **H2**: `getEngine()` 返回的 `data.engine.image` 不存在或为空数组,if 条件未进入
3. **H3**: 比例按钮渲染区域被父容器 CSS 隐藏(如 `display: none`, `visibility: hidden`, `overflow: hidden`
4. **H4**: `ratioOptions` 在某处被重置为空数组
5. **H5**: 组件条件渲染导致整个比例区域未挂载
## Evidence Points
- EP1: 检查 `ratioOptions` 初始值是否为 8 个元素的数组
- EP2: 检查 `getEngine()` 返回后 `data.engine.image` 是否存在
- EP3: 检查渲染区域父容器的 CSS 是否有隐藏属性
- EP4: 搜索代码中是否有 `setRatioOptions([])` 调用
## Status
[OPEN] - 调试中
## Log File
`trae-debug-log-ratio-options-not-showing.ndjson`
File diff suppressed because one or more lines are too long
+14 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 897 KiB

+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-Bc2N-TY0.js"></script>
<script type="module" crossorigin src="/assets/index-B5pqnZWD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BhPFzWLH.css">
</head>
<body>
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 897 KiB

+8 -1
View File
@@ -709,8 +709,15 @@ export async function deleteOAuthAccount(params: DeleteOAuthAccountParams): Prom
export async function getAllOAuthAccountList(): Promise<any> {
return api.post(`/upload-material/oauth_account_list`);
}
// 获取用户全部授权账户列表 resources_material_ids pre_test_template_id
export async function submitPreTest(params: { resources_material_ids: any[]; pre_test_template_id: string }): Promise<any> {
return api.post(`/resources-material/pre-commit`, params);
}
// 首页素材案例头
export async function getHomeCaseHeader(): Promise<any> {
return api.get(`/home-materials/categories`);
}
// 首页素材按钮资源
export async function getHomeCaseButton(id: string,limit:number=5): Promise<any> {
return api.get(`/home-materials?category_id=${id}&limit_per_category=${limit}&include_empty_categories=false&response_mode=grouped&page=1&page_size=20`);
}
+183 -135
View File
@@ -143,16 +143,17 @@ const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data })
color: '#475569',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{/* <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ef4444' : '#6366f1' }} />
{rawPercent.toFixed(1)}%
</span>
</span> */}
{data.enabled ? (
<span style={{ fontWeight: 500, color: isOver ? '#ef4444' : '#1e293b' }}>
{used.toFixed(2)} / {total.toFixed(2)} {unit}
</span>
<></>
) : (
<span style={{ fontWeight: 500, color: '#1e293b' }}>
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ef4444' : '#6366f1', marginRight: 4 }} />
使 {usedAuto.val} {usedAuto.unit}
</span>
)}
@@ -162,23 +163,61 @@ const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data })
<div
style={{
width: '100%',
height: 6,
height: 20,
background: '#e2e8f0',
borderRadius: 3,
borderRadius: 2,
overflow: 'hidden',
position: 'relative',
}}
>
<div style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
background: 'transparent',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 6,
fontSize: 12,
color: isOver ? '#ffffffff' : '#000000ff',
padding: '0 8px',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ffffffff' :'#000000ff' }} />
{rawPercent.toFixed(1)}%
</span>
{data.enabled ? (
<span style={{ fontWeight: 500, color: isOver ? '#ffffffff' : '#000000ff' }}>
{used.toFixed(2)} / {total.toFixed(2)} {unit}
</span>
) : (
<span style={{ fontWeight: 500, color: '#1e293b' }}>
使 {usedAuto.val} {usedAuto.unit}
</span>
)}
</div>
<div
style={{
width: `${barPercent}%`,
height: '100%',
background: isOver
? 'linear-gradient(90deg, #ef4444, #dc2626)'
: 'linear-gradient(90deg, #6366f1, #8b5cf6)',
borderRadius: 3,
background: rawPercent < 50
? 'linear-gradient(90deg, #279951, #b7fad0)'
: rawPercent < 85
? 'linear-gradient(90deg, #ffd759, #fff6d4)'
: 'linear-gradient(90deg, #ef4444, #dc2626)',
borderRadius: 2,
transition: 'width 0.4s ease',
}}
/>
</div>
<div
style={{
@@ -283,9 +322,9 @@ const AppLayout: React.FC = () => {
const [contactHovered, setContactHovered] = useState(false);
const [contactForm] = Form.useForm();
const [submittingContact, setSubmittingContact] = useState(false);
const [contactPosition, setContactPosition] = useState<{ x: number; y: number }>(() => ({
x: 24,
y: window.innerHeight * 0.75
const [contactPosition, setContactPosition] = useState<{ x: number; y: number }>(() => ({
x: 24,
y: window.innerHeight * 0.75
}));
const [isDragging, setIsDragging] = useState(false);
const hasMovedRef = useRef(false);
@@ -334,6 +373,7 @@ const AppLayout: React.FC = () => {
limitValue: string;
limitUnit: string;
} | null>(null);
const [isMobile, setIsMobile] = useState(false);
const PENDING_ORDER_KEY = 'pending_payment_order';
@@ -387,6 +427,13 @@ const AppLayout: React.FC = () => {
}, []);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 767);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const handleContactMouseDown = (e: React.MouseEvent) => {
if (e.button === 0) {
setIsDragging(true);
@@ -400,16 +447,16 @@ const AppLayout: React.FC = () => {
const handleContactMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const deltaX = Math.abs(e.clientX - dragStartRef.current.x);
const deltaY = Math.abs(e.clientY - dragStartRef.current.y);
if (deltaX > 5 || deltaY > 5) {
hasMovedRef.current = true;
}
const newY = Math.max(60, Math.min(window.innerHeight - 60, e.clientY - (dragStartRef.current.y - contactPosition.y)));
setContactPosition(prev => ({ x: prev.x, y: newY }));
};
@@ -417,7 +464,7 @@ const AppLayout: React.FC = () => {
const moved = hasMovedRef.current;
setIsDragging(false);
hasMovedRef.current = false;
if (!moved) {
setContactModalOpen(true);
}
@@ -672,12 +719,12 @@ const AppLayout: React.FC = () => {
return (
<div key={item.id}>
<div
<div
onClick={() => {
if (!(isGroup || hasChildren) && item.path) {
navigate(item.path);
}
}}
}}
style={{
display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
@@ -712,7 +759,7 @@ const AppLayout: React.FC = () => {
{item.label}
</span>
</div>
{(isGroup || hasChildren) && (
<div style={{ overflow: 'hidden' }}>
{(childMap[item.id] || []).map(c => renderMenuItem(c, depth + 1))}
@@ -752,7 +799,7 @@ const AppLayout: React.FC = () => {
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
overflow: 'hidden',
}}
>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
@@ -803,9 +850,9 @@ const AppLayout: React.FC = () => {
<span></span>
</div>
{/* 资源存储容量展示(来自 getUser.resourceCapacity */}
<StorageCard data={resourceCapacity} />
<StorageCard data={resourceCapacity} />
<div style={{ padding: '16px 16px', flexShrink: 0 ,paddingTop: 0}}>
<div style={{ padding: '16px 16px', flexShrink: 0, paddingTop: 0 }}>
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
<div style={{
display: 'flex', alignItems: 'center',
@@ -832,7 +879,7 @@ const AppLayout: React.FC = () => {
flexShrink: 0,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#1e293b', fontSize: 16, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: -0.01 }}>
{user?.username}
@@ -851,7 +898,7 @@ const AppLayout: React.FC = () => {
</div>
</Dropdown>
</div>
</div>
@@ -876,7 +923,7 @@ const AppLayout: React.FC = () => {
padding: '24px 32px 32px',
border: '1px solid rgba(0, 0, 0, 0.06)',
}}>
<Outlet />
{!isMobile && <Outlet />}
</div>
</div>
@@ -896,7 +943,7 @@ const AppLayout: React.FC = () => {
</div>
<div className="mobile-content">
<Outlet />
{isMobile && <Outlet />}
</div>
<Drawer
@@ -967,7 +1014,7 @@ const AppLayout: React.FC = () => {
{menuIcon}
</span>
)}
<span className="mobile-menu-label" style={{
<span className="mobile-menu-label" style={{
paddingLeft: menuType === 'group' ? 0 : 0,
color: menuType === 'group' ? '#94a3b8' : (isActive ? '#4f46e5' : '#475569'),
fontWeight: menuType === 'group' ? 600 : (isActive ? 600 : 400),
@@ -1002,7 +1049,7 @@ const AppLayout: React.FC = () => {
<span className="mobile-menu-icon" style={{ color: childActive ? '#6366f1' : '#94a3b8' }}>
{childIcon}
</span>
<span className="mobile-menu-label" style={{
<span className="mobile-menu-label" style={{
color: childActive ? '#4f46e5' : '#64748b',
fontWeight: childActive ? 600 : 400,
fontSize: 14,
@@ -1091,7 +1138,7 @@ const AppLayout: React.FC = () => {
</span>
<span className="mobile-menu-label" style={{ color: '#fff', fontWeight: 600 }}></span>
</div>
<div
className="mobile-menu-item"
onClick={() => {
@@ -1133,109 +1180,10 @@ const AppLayout: React.FC = () => {
<Modal title={<Space><GiftOutlined /></Space>} open={rechargeModalOpen}
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
footer={null} width={680}
width={680}
className="recharge-modal"
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 16 }}>
<WalletOutlined style={{ color: '#6366f1' }} />
<Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}></Typography.Text>
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
</Space>
<div style={{
padding: '12px 16px',
background: 'rgba(99, 102, 241, 0.06)',
borderRadius: 10,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
/
<Typography.Text
style={{
color: '#ff0000ff',
cursor: 'pointer',
textDecoration: 'underline',
}}
onClick={() => { setRechargeModalOpen(false); setContactModalOpen(true); }}
></Typography.Text>
</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{rechargeOptions.map((opt, idx) => {
const g = GRADIENTS[idx % GRADIENTS.length];
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
return (
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
}}>
{opt.description && (
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#fff', boxShadow: `0 6px 16px ${g.shadow}`,
}}>{g.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
<Typography.Text style={{ fontSize: 14, fontWeight: 600, color: '#6366f1' }}>{totalCredits.toLocaleString()} </Typography.Text>
</div>
<div style={{
fontSize: 22, fontWeight: 800, marginTop: 4,
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
}}>¥{opt.price}</div>
</div>
</div>
</div>
);
})}
</div>
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
</Typography.Text>
</div>
) : (
<div style={{ marginTop: 20, marginBottom: 8 }}>
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}></Typography.Text>
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
style={{ display: 'flex', gap: 12 }}>
{enabledMethods.alipay && (
<Radio.Button value="alipay" style={{
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
}}>
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
</Radio.Button>
)}
{enabledMethods.wechat && (
<Radio.Button value="wechat" style={{
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
}}>
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
</Radio.Button>
)}
</Radio.Group>
</div>
)}
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '12px 0 0', borderTop: '1px solid #f0f0f0' }}>
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}></Button>
<Button type="primary" size="large" disabled={!selectedPlan || (!enabledMethods.alipay && !enabledMethods.wechat)} loading={paying}
onClick={async () => {
@@ -1289,6 +1237,106 @@ const AppLayout: React.FC = () => {
</Button>
</div>
}>
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 16 }}>
<WalletOutlined style={{ color: '#6366f1' }} />
<Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}></Typography.Text>
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
</Space>
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
<div style={{ marginBottom: 16, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
</Typography.Text>
</div>
) : (
<div style={{ marginBottom: 16 }}>
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}></Typography.Text>
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
style={{ display: 'flex', gap: 12 }}>
{enabledMethods.alipay && (
<Radio.Button value="alipay" style={{
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
}}>
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
</Radio.Button>
)}
{enabledMethods.wechat && (
<Radio.Button value="wechat" style={{
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
}}>
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
</Radio.Button>
)}
</Radio.Group>
</div>
)}
<div style={{
padding: '12px 16px',
background: 'rgba(99, 102, 241, 0.06)',
borderRadius: 10,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
/
<Typography.Text
style={{
color: '#ff0000ff',
cursor: 'pointer',
textDecoration: 'underline',
}}
onClick={() => { setRechargeModalOpen(false); setContactModalOpen(true); }}
></Typography.Text>
</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{rechargeOptions.map((opt, idx) => {
const g = GRADIENTS[idx % GRADIENTS.length];
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
return (
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
}}>
{opt.description && (
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#fff', boxShadow: `0 6px 16px ${g.shadow}`,
}}>{g.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
<Typography.Text style={{ fontSize: 14, fontWeight: 600, color: '#6366f1' }}>{totalCredits.toLocaleString()} </Typography.Text>
</div>
<div style={{
fontSize: 22, fontWeight: 800, marginTop: 4,
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
}}>¥{opt.price}</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</Modal>
@@ -1436,7 +1484,7 @@ const AppLayout: React.FC = () => {
<NotificationPopup />
<div
<div
className="contact-button-wrapper"
style={{
right: `${contactPosition.x}px`,
+325 -67
View File
@@ -46,6 +46,7 @@ import {
LayoutOutlined,
ArrowUpOutlined,
DownloadOutlined,
ReloadOutlined,
} from '@ant-design/icons';
@@ -139,13 +140,52 @@ const AIChatPage: React.FC = () => {
setEngineOptions,
setEnginesele,
setInputValue,
currentMedia,
setCurrentMedia,
} = useAppStore();
// 获取当前选中引擎的媒体上传限制
const currentEngineList = mediaType === 'image' ? enginesele?.image : enginesele?.video;
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
const maxImageCount = currentEngine?.maxImageCount ?? 4;
const maxVideoCount = currentEngine?.maxVideoCount ?? 1;
const [uploading, setUploading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [currentMedia, setCurrentMedia] = useState<{ name: string; type: 'image' | 'video'; url: string }[]>([]);
// @ 提及相关状态
const [mentionVisible, setMentionVisible] = useState(false);
const mentionInputRef = useRef<any>(null);
const [mentionPosition, setMentionPosition] = useState({ top: 0, left: 0 });
// 数字转中文数字(一、二、三、四)
const numberToChinese = (num: number): string => {
const map = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
if (num <= 10) return map[num];
if (num < 20) return '十' + map[num % 10];
if (num < 100) {
const tens = Math.floor(num / 10);
const ones = num % 10;
return map[tens] + '十' + (ones ? map[ones] : '');
}
return String(num);
};
// 根据媒体列表生成标签(图片一、图片二、视频一、视频二...)
const generateMediaLabels = (media: { type: 'image' | 'video' }[]) => {
let imgCount = 0;
let vidCount = 0;
return media.map((m) => {
if (m.type === 'image') {
imgCount++;
return `图片${imgCount}`;
} else {
vidCount++;
return `视频${vidCount}`;
}
});
};
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string>('');
@@ -393,44 +433,20 @@ const AIChatPage: React.FC = () => {
setCountType(targetEngine.id);
}
}
})
.catch(() => {
});
getCreditRatios()
.then((data: any) => {
// console.log('积分', data);
setCreditRatios(data.video || []);
setCimage(data.image || []);
})
.catch((error) => {
});
calculateCredits().then((data: any) => {
// console.log('积分计算', data);
// 保存积分计算数据
setCreditCalculationData(data);
})
getgen_list(Pagebreak).then((data: any) => {
let mess_list = data.items
let total = data.total
setGen_list(mess_list)
setTotalnumber(total)
})
getParameters()
.then((data) => {
let supportedSizes = (data as any).items?.[0]?.supportedSizes || {};
let supportedResolutions = [];
let twokwidth = [];
let fourkwidth = [];
let supportedSizes = (data as any).engine?.image?.[0]?.supported_sizes || {};
let supportedResolutions: string[] = [];
let twokwidth: string[] = [];
let fourkwidth: string[] = [];
for (let key in supportedSizes["2K"]) {
supportedResolutions.push(key);
twokwidth.push(supportedSizes["2K"][key]);
}
const newRatioOptions = supportedResolutions.map((res: any, index: number) => ({
const newRatioOptions = supportedResolutions.map((res: any) => ({
value: res,
label: String(index),
label: res,
}));
setRatioOptions(newRatioOptions);
@@ -444,8 +460,67 @@ const AIChatPage: React.FC = () => {
{ value: '2K', label: '高清 2K' },
{ value: '4K', label: '超清 4K' },
]);
})
.catch(() => { });
.catch(() => {
});
// getCreditRatios()
// .then((data: any) => {
// // console.log('积分', data);
// setCreditRatios(data.video || []);
// setCimage(data.image || []);
// })
// .catch((error) => {
// });
calculateCredits().then((data: any) => {
// console.log('积分计算', data);
// 保存积分计算数据
setCreditCalculationData(data);
})
getgen_list(Pagebreak).then((data: any) => {
let mess_list = data.items
let total = data.total
setGen_list(mess_list)
setTotalnumber(total)
})
// getParameters()
// .then((data) => {
// let supportedSizes = (data as any).items?.[0]?.supportedSizes || {};
// let supportedResolutions = [];
// let twokwidth = [];
// let fourkwidth = [];
// for (let key in supportedSizes["2K"]) {
// supportedResolutions.push(key);
// twokwidth.push(supportedSizes["2K"][key]);
// }
// const newRatioOptions = supportedResolutions.map((res: any, index: number) => ({
// value: res,
// label: String(index),
// }));
// setRatioOptions(newRatioOptions);
// for (let key in supportedSizes["4K"]) {
// fourkwidth.push(supportedSizes["4K"][key]);
// }
// const newWidthandHeight = [twokwidth, fourkwidth];
// setWidthandHeight(newWidthandHeight);
// setResolutionOptions([
// { value: '2K', label: '高清 2K' },
// { value: '4K', label: '超清 4K' },
// ]);
// })
// .catch(() => { });
}, []);
@@ -770,6 +845,18 @@ const AIChatPage: React.FC = () => {
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) {
@@ -777,17 +864,17 @@ const AIChatPage: React.FC = () => {
return false;
}
// 验证图片数量(最多4张)
// 验证图片数量
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
if (isImage && imageCount >= 4) {
message.error('最多上传4张图片');
if (isImage && imageCount >= maxImage) {
message.error(`该引擎最多上传${maxImage}张图片`);
return false;
}
// 验证视频数量(最多1个)
// 验证视频数量
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
if (isVideo && videoCount >= 1) {
message.error('最多上传1个视频');
if (isVideo && videoCount >= maxVideo) {
message.error(`该引擎最多上传${maxVideo}个视频`);
return false;
}
@@ -799,11 +886,15 @@ const AIChatPage: React.FC = () => {
const uploadFn = isImage ? uploadImage : uploadVideo;
const res = await uploadFn(file);
// 添加到统一的媒体列表
setCurrentMedia((prev) => [...prev, {
const mediaType: 'image' | 'video' = isImage ? 'image' : 'video';
const newList = [...currentMedia, {
name: file.name,
type: isImage ? 'image' : 'video',
type: mediaType,
url: res.url,
}]);
label: '',
}];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
message.success(`${isImage ? '图片' : '视频'}上传成功`);
} catch (error) {
message.error('上传失败');
@@ -817,7 +908,9 @@ const AIChatPage: React.FC = () => {
const handleRemoveMedia = (index: number) => {
setCurrentMedia((prev) => prev.filter((_, i) => i !== index));
const newList = currentMedia.filter((_, i) => i !== index);
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
};
@@ -828,6 +921,63 @@ const AIChatPage: React.FC = () => {
}
};
// 检测光标前的 @ 符号
const checkMention = (textarea: HTMLTextAreaElement, value: string) => {
const cursorPos = textarea.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos);
const atMatch = textBeforeCursor.match(/@([^@\s]*)$/);
if (atMatch && currentMedia.length > 0) {
setMentionVisible(true);
return true;
}
setMentionVisible(false);
return false;
};
// 输入框变化处理
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setInputValue(value);
const textarea = e.target;
checkMention(textarea, value);
};
// 键盘事件处理(ESC 关闭提及、Tab/Enter 选择)
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (mentionVisible) {
if (e.key === 'Escape') {
setMentionVisible(false);
}
}
};
// 插入 @ 提及
const insertMention = (label: string) => {
const textarea = mentionInputRef.current?.resizableTextArea?.textArea;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBefore = inputValue.slice(0, cursorPos);
const textAfter = inputValue.slice(cursorPos);
const atIndex = textBefore.lastIndexOf('@');
if (atIndex === -1) {
setMentionVisible(false);
return;
}
const newValue = textBefore.slice(0, atIndex) + `@${label} ` + textAfter;
setInputValue(newValue);
setMentionVisible(false);
setTimeout(() => {
const pos = atIndex + label.length + 2;
textarea.focus();
textarea.setSelectionRange(pos, pos);
}, 0);
};
const handleClosePreview = () => {
@@ -961,9 +1111,9 @@ const AIChatPage: React.FC = () => {
border: '1px solid rgba(99, 102, 241, 0.08)',
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} />
<div>
<div style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: '15%', height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} />
<div style={{ flex: 1, minWidth: 0, textAlign: 'center' }}>
<h2 style={{
margin: 0,
fontSize: 18,
@@ -971,7 +1121,7 @@ const AIChatPage: React.FC = () => {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
backgroundClip: 'text',
}}>
AI创作
</h2>
@@ -979,7 +1129,7 @@ const AIChatPage: React.FC = () => {
/
</p>
</div>
<div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #8b5cf6, #6366f1, transparent)', borderRadius: 1 }} />
<div style={{ width: '15%', height: 2, background: 'linear-gradient(90deg, transparent, #8b5cf6, #6366f1, transparent)', borderRadius: 1 }} />
</div>
</div>
@@ -1125,8 +1275,55 @@ const AIChatPage: React.FC = () => {
{msg.genType === 'image' ? '图片生成' : '视频生成'}
</span>
</div>
{/* 删除按钮 - 右上角 */}
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
{/* 附件详情 - 右上角 */}
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<div style={{ position: 'absolute', top: 8, right: 8, zIndex: 100 }}>
<span style={{ padding: '4px 12px', borderRadius: 16, color: '#6366f1', cursor: 'pointer', fontWeight: 500, border: '1px solid rgba(99, 102, 241, 0.2)', background: 'rgba(99, 102, 241, 0.04)' }} onClick={(e) => { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); setAttachmentPopupPosition({ x: rect.left, y: rect.top - 10 }); setAttachmentPopupMessageId(msg.id); setAttachmentPopupVisible(true); }}></span>
</div>
)}
{/* 操作按钮 - 右下角 */}
<div style={{ position: 'absolute', bottom: 8, right: 8, zIndex: 100, display: 'flex', gap: 6 }}>
<button
onClick={(e) => {
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 || '',
})));
} else {
setCurrentMedia([]);
}
msgApi.success('已加载到编辑区');
}}
style={{
height: 28,
padding: '0 10px',
borderRadius: 8,
border: 'none',
background: 'rgba(99, 102, 241, 0.08)',
color: '#6366f1',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
gap: 4,
fontSize: 12,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.08)';
}}
>
<ReloadOutlined style={{ fontSize: 12 }} />
</button>
<Popconfirm
title="确定要删除吗?"
onConfirm={async () => {
@@ -1148,8 +1345,8 @@ const AIChatPage: React.FC = () => {
<button
onClick={(e) => e.stopPropagation()}
style={{
width: 28,
height: 28,
padding: '0 10px',
borderRadius: 8,
border: 'none',
background: 'rgba(99, 102, 241, 0.08)',
@@ -1159,7 +1356,8 @@ const AIChatPage: React.FC = () => {
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
padding: 0,
gap: 4,
fontSize: 12,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
@@ -1171,9 +1369,9 @@ const AIChatPage: React.FC = () => {
}}
>
<DeleteOutlined style={{ fontSize: 12 }} />
</button>
</Popconfirm>
</div>
{/* 文本内容 */}
@@ -1261,7 +1459,10 @@ const AIChatPage: React.FC = () => {
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'rgba(254, 226, 226, 0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<WarningOutlined style={{ color: '#ef4444', fontSize: 22 }} />
</div>
<span style={{ fontSize: 14, color: '#94a3b8' }}></span>
<span style={{ fontSize: 14, color: '#94a3b8' }}>退</span>
{msg.errorMessage && (
<span style={{ fontSize: 13, color: '#ef4444', textAlign: 'center', padding: '0 8px', lineHeight: 1.5 }}>{msg.errorMessage}</span>
)}
</div>
) : (
<>
@@ -1300,11 +1501,6 @@ const AIChatPage: React.FC = () => {
</div>
)}
<div style={{ textAlign: 'right' }}>
{msg.mediaReferences && msg.mediaReferences.length > 0 && (
<span style={{ padding: '4px 12px', borderRadius: 16, color: '#6366f1', cursor: 'pointer', fontWeight: 500, border: '1px solid rgba(99, 102, 241, 0.2)', background: 'rgba(99, 102, 241, 0.04)' }} onClick={(e) => { e.stopPropagation(); const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); setAttachmentPopupPosition({ x: rect.left, y: rect.top - 10 }); setAttachmentPopupMessageId(msg.id); setAttachmentPopupVisible(true); }}></span>
)}
</div>
</div>
@@ -1444,7 +1640,7 @@ 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 }}>
<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' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
@@ -1457,6 +1653,9 @@ const AIChatPage: React.FC = () => {
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8 }}
/>
)}
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 500 }}>
{media.label}
</span>
{/* 删除已上传媒体按钮 */}
<button
onClick={() => handleRemoveMedia(idx)}
@@ -1495,14 +1694,18 @@ const AIChatPage: React.FC = () => {
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="image/*,video/*"
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
showUploadList={false}
beforeUpload={handleUpload}
>
<Tooltip title={`图片${currentMedia.filter(m => m.type === 'image').length}/4,视频${currentMedia.filter(m => m.type === 'video').length}/1`}>
<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,
@@ -1539,10 +1742,15 @@ const AIChatPage: React.FC = () => {
{/* 文本输入框 */}
<TextArea
ref={mentionInputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onKeyPress={handleKeyPress}
placeholder="上传最多4张参考图,输入提示词描述您想生成的画面..."
placeholder={mediaType === 'image'
? `上传最多${maxImageCount}张参考图,输入提示词描述您想生成的画面...`
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
}
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
flex: 1,
@@ -1554,14 +1762,64 @@ const AIChatPage: React.FC = () => {
fontSize: 14,
lineHeight: 1.5,
color: '#1e293b',
// resize: 'none',
// boxShadow: 'none',
// padding: '8px 12px',
}}
disabled={loading}
/>
{/* @ 提及下拉列表 */}
{mentionVisible && currentMedia.length > 0 && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: 70,
marginBottom: 8,
zIndex: 1000,
background: '#fff',
borderRadius: 12,
boxShadow: '0 8px 28px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06)',
border: '1px solid #e2e8f0',
padding: 6,
minWidth: 160,
maxHeight: 260,
overflowY: 'auto',
}}
>
{currentMedia.map((media, idx) => (
<div
key={idx}
onClick={() => insertMention(media.label)}
style={{
padding: '8px 12px',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: '#334155',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.08)';
e.currentTarget.style.color = '#6366f1';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#334155';
}}
>
{media.type === 'image' ? (
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
) : (
<VideoCameraOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
)}
<span>{media.label}</span>
</div>
))}
</div>
)}
{/* 发送按钮 */}
<Button
type="primary"
+181 -51
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Button, Input, Tabs, Tag, Upload, message } from 'antd';
import React, { useEffect, useState, useRef } from 'react';
import { Button, Input, Tabs, Tag, Upload, message, Modal } from 'antd';
import {
FileTextOutlined,
ScissorOutlined,
@@ -9,9 +9,10 @@ import {
VideoCameraOutlined,
PictureOutlined,
ThunderboltOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getmedit } from '../api';
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
import hot from '../assets/homebtn1.png';
import mashup from '../assets/homebtn2.png';
@@ -39,6 +40,29 @@ const HomePage: React.FC = () => {
const [activeTab, setActiveTab] = useState('project');
const [inputValue, setInputValue] = useState('');
const [mockVideos, setMockVideos] = useState<any[]>([]);
const [caseHeader, setCaseHeader] = useState<any[]>([]);
const [activeCaseTab, setActiveCaseTab] = useState<string>('');
const [caseAssets, setCaseAssets] = useState<any[]>([]);
const [previewAsset, setPreviewAsset] = useState<any>(null);
const previewVideoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
getHomeCaseHeader().then((res: any) => {
// console.log('caseHeader:', res);
if (res?.items?.length > 0) {
setCaseHeader(res.items);
const firstId = res.items[0].id;
setActiveCaseTab(firstId);
// 初始请求第一个 tab 的数据
getHomeCaseButton(firstId).then((btnRes: any) => {
console.log('caseButton:', btnRes);
if (btnRes?.categories?.[0]?.assets) {
setCaseAssets(btnRes.categories[0].assets);
}
});
}
});
}, []);
useEffect(() => {
const fetchAll = async () => {
@@ -75,21 +99,21 @@ const HomePage: React.FC = () => {
{
icon: <FileTextOutlined style={{ fontSize: 24, color: '#6366f1' }} />,
title: '爆款开头复刻',
description: '一键复刻热门视频开篇,快速替换自有商品素材',
description: '上传参考视频与产品图片,一键复刻爆款视频开',
action: '立即创作',
path: '/initial',
},
{
icon: <ScissorOutlined style={{ fontSize: 24, color: '#f97316' }} />,
title: '批量混剪',
description: '多素材批量自动剪辑,智能筛选高清优质镜头片段',
title: '拆镜复刻',
description: '一键拆解画面分镜,助力仿拍或创作',
action: '开始混剪',
path: '/removelens',
},
{
icon: <RobotOutlined style={{ fontSize: 24, color: '#10b981' }} />,
title: 'AI成片',
description: 'AI全自动快速出片,支持文案生成/上传参考素材制作',
description: '输入想法、剧本或上传参考,智能生成视频/图片',
action: '立即生成',
path: '/conversation',
},
@@ -707,8 +731,6 @@ const HomePage: React.FC = () => {
gap: 6,
fontSize: 12,
color: '#64748b',
}}>
{(() => {
// 显示所属模块,而非媒体类型
@@ -773,68 +795,176 @@ const HomePage: React.FC = () => {
</div>
</div>
<div style={{
{/* <div style={{
fontSize: 13, color: '#6366f1', cursor: 'pointer', fontWeight: 500,
display: 'flex', alignItems: 'center', gap: 2,
}}>
更多案例
<ArrowRightOutlined style={{ fontSize: 11 }} />
</div>
</div> */}
</div>
<div className="stagger-children" style={{ display: 'flex', gap: 14 }}>
{materialCases.map((caseUrl, index) => (
{/* Tab切换 */}
<div style={{ marginBottom: 20 }}>
<Tabs
activeKey={activeCaseTab}
onChange={(key) => {
setActiveCaseTab(key);
getHomeCaseButton(key).then((btnRes: any) => {
if (btnRes?.categories?.[0]?.assets) {
setCaseAssets(btnRes.categories[0].assets);
} else {
setCaseAssets([]);
}
});
}}
items={caseHeader.map((item: any) => ({
key: item.id,
label: item.name,
}))}
className="homepage-tabs"
/>
</div>
{/* ========== 素材案例列表(与近期作品一致) ========== */}
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
{caseAssets.length === 0 ? (
<div style={{
gridColumn: '1 / -1',
padding: '60px 0',
textAlign: 'center',
color: '#94a3b8',
fontSize: 14,
}}>
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
<div></div>
</div>
) : caseAssets.map((asset: any, index: number) => (
<div
key={index}
key={asset.id || index}
className="project-card"
onClick={() => setPreviewAsset(asset)}
style={{
flex: 1,
aspectRatio: '16/9',
borderRadius: 12,
overflow: 'hidden',
cursor: 'pointer',
background: '#f1f5f9',
position: 'relative',
}}
onMouseEnter={(e) => {
const overlay = e.currentTarget.querySelector('.case-overlay') as HTMLElement | null;
if (overlay) overlay.style.opacity = '1';
}}
onMouseLeave={(e) => {
const overlay = e.currentTarget.querySelector('.case-overlay') as HTMLElement | null;
if (overlay) overlay.style.opacity = '0';
background: '#fff',
border: '1px solid #e2e8f0',
}}
>
<img
src={caseUrl}
alt={`素材案例 ${index + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
hover
<div
className="case-overlay"
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(180deg, transparent 40%, rgba(99,102,241,0.75) 100%)',
opacity: 0,
transition: 'opacity 0.3s',
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
padding: 14,
color: '#fff',
fontSize: 13,
fontWeight: 600,
letterSpacing: 0.5,
}}
>
{index + 1}
{/* 媒体区域 16:9 */}
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
{asset.mediaType === 'video' ? (
<>
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
muted
playsInline
/>
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.2)',
}}>
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
</div>
</>
) : (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
alt={asset.title || `素材 ${index + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
</div>
{/* 底部信息栏 */}
<div style={{
padding: '10px 12px',
background: '#f8fafc',
}}>
<div style={{
fontSize: 12,
color: '#64748b',
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{asset.title || `素材 ${index + 1}`}
</div>
</div>
</div>
))}
</div>
</div>
{/* ========== 预览弹窗 ========== */}
<Modal
open={!!previewAsset}
onCancel={() => {
previewVideoRef.current?.pause();
setPreviewAsset(null);
}}
footer={null}
width={760}
centered
className="preview-modal"
bodyStyle={{
padding: 0,
background: '#fff',
borderRadius: 16,
overflow: 'hidden',
}}
>
{/* 固定比例容器 16:9 */}
<div style={{
width: '100%',
paddingTop: '56.25%',
position: 'relative',
// background: '#000',
}}>
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{previewAsset?.mediaType === 'video' ? (
<video
ref={previewVideoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset.url}`}
controls
autoPlay
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
alt={previewAsset?.title}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)}
</div>
</div>
{/* 底部标题栏 */}
{previewAsset?.title && (
<div style={{
padding: '14px 20px',
fontSize: 14,
color: '#4b5563',
fontWeight: 500,
borderTop: '1px solid #f1f5f9',
textAlign: 'center',
}}>
{previewAsset.title}
</div>
)}
</Modal>
</div>
);
};
@@ -459,7 +459,8 @@ const GenerateConver: React.FC = () => {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
backgroundClip: 'text',
textAlign: 'center',
}}>
</h2>
+2 -1
View File
@@ -125,7 +125,8 @@ const ProjectsPage: React.FC = () => {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
backgroundClip: 'text',
textAlign: 'center',
}}>
</h2>
+2 -1
View File
@@ -186,7 +186,8 @@ export default function VideoFrameExtractor() {
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
backgroundClip: 'text',
textAlign: 'center',
}}>
AI视频拆镜工作台
</h2>
+5
View File
@@ -32,6 +32,7 @@ interface AppState {
};
enginesele: any;
inputValue: string;
currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string }[];
fetchProjects: () => Promise<void>;
createProject: (name: string, industry: Industry) => Promise<Project>;
@@ -58,6 +59,7 @@ interface AppState {
setVideoResolution: (resolution: string) => void;
setEnginesele: (enginesele: any) => void;
setInputValue: (inputValue: string) => void;
setCurrentMedia: (currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string }[]) => void;
resetGenerationConfig: () => void;
}
@@ -83,6 +85,7 @@ export const useAppStore = create<AppState>((set, get) => ({
},
enginesele: [],
inputValue: '',
currentMedia: [],
fetchProjects: async () => {
set({ loading: true });
@@ -172,6 +175,7 @@ export const useAppStore = create<AppState>((set, get) => ({
setVideoResolution: (resolution) => set({ videoResolution: resolution }),
setEnginesele: (enginesele) => set({ enginesele }),
setInputValue: (inputValue) => set({ inputValue }),
setCurrentMedia: (currentMedia) => set({ currentMedia }),
resetGenerationConfig: () => set({
mediaType: 'image',
countType: '请选择',
@@ -189,5 +193,6 @@ export const useAppStore = create<AppState>((set, get) => ({
},
enginesele: [],
inputValue: '',
currentMedia: [],
}),
}));