Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
-548
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-ChXF-ot2.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DveJ8Oia.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -22,6 +22,9 @@ interface CreditRatio {
|
||||
ratio: number;
|
||||
baseCredits: number;
|
||||
perSecondCredits: number;
|
||||
inputVideoRatio: number;
|
||||
inputVideoBaseCredits: number;
|
||||
inputVideoPerSecondCredits: number;
|
||||
}
|
||||
|
||||
interface CreditRatioFormValues {
|
||||
@@ -31,6 +34,9 @@ interface CreditRatioFormValues {
|
||||
ratio: number;
|
||||
baseCredits: number;
|
||||
perSecondCredits?: number;
|
||||
inputVideoRatio?: number;
|
||||
inputVideoBaseCredits?: number;
|
||||
inputVideoPerSecondCredits?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
||||
@@ -117,7 +123,7 @@ const AdminCreditRatios: React.FC = () => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
model_config_id: values.modelConfigId,
|
||||
gen_type: values.genType,
|
||||
resolution: values.resolution,
|
||||
@@ -125,6 +131,11 @@ const AdminCreditRatios: React.FC = () => {
|
||||
base_credits: values.baseCredits,
|
||||
per_second_credits: values.genType === 'image' ? 0 : (values.perSecondCredits || 0),
|
||||
};
|
||||
if (values.genType === 'video') {
|
||||
payload.input_video_ratio = values.inputVideoRatio ?? 1.0;
|
||||
payload.input_video_base_credits = values.inputVideoBaseCredits ?? 0;
|
||||
payload.input_video_per_second_credits = values.inputVideoPerSecondCredits ?? 0;
|
||||
}
|
||||
if (modal.ratio) {
|
||||
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
||||
message.success('已更新');
|
||||
@@ -174,10 +185,21 @@ const AdminCreditRatios: React.FC = () => {
|
||||
ratio: ratio.ratio,
|
||||
baseCredits: ratio.baseCredits,
|
||||
perSecondCredits: ratio.perSecondCredits,
|
||||
inputVideoRatio: ratio.inputVideoRatio,
|
||||
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
|
||||
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ genType: 'video', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 });
|
||||
form.setFieldsValue({
|
||||
genType: 'video',
|
||||
ratio: 1.0,
|
||||
baseCredits: 60,
|
||||
perSecondCredits: 2,
|
||||
inputVideoRatio: 1.0,
|
||||
inputVideoBaseCredits: 0,
|
||||
inputVideoPerSecondCredits: 0.5,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,13 +238,38 @@ const AdminCreditRatios: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '示例计算', key: 'example', width: 120,
|
||||
title: '视频倍率', dataIndex: 'inputVideoRatio', width: 100,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : (
|
||||
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
||||
)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '视频基础积分', dataIndex: 'inputVideoBaseCredits', width: 110,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '视频每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 110,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '示例计算(视频15秒,上传视频15秒)', key: 'example', width: 140,
|
||||
render: (_: any, r: CreditRatio) => {
|
||||
let total: number;
|
||||
if (r.genType === 'image') {
|
||||
total = Math.round(r.baseCredits * r.ratio);
|
||||
} else {
|
||||
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
|
||||
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
|
||||
total += Math.round(
|
||||
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 15) * r.inputVideoRatio
|
||||
);
|
||||
}
|
||||
}
|
||||
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} 积分</Typography.Text>;
|
||||
},
|
||||
@@ -299,7 +346,7 @@ const AdminCreditRatios: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 860 }}
|
||||
scroll={{ x: 1180 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -349,6 +396,31 @@ const AdminCreditRatios: React.FC = () => {
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{genType !== 'image' && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f5f5ff',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e0e0ff',
|
||||
}}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#4f46e5' }}>
|
||||
传入视频价格
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="inputVideoRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频倍率' }]}>
|
||||
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputVideoBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频基础积分' }]}>
|
||||
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputVideoPerSecondCredits" label="每秒积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频每秒积分' }]}>
|
||||
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -319,6 +319,7 @@ export interface AdminGenerationRecord {
|
||||
username: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
industry?: string;
|
||||
originalPrompt: string;
|
||||
optimizedPrompt: string;
|
||||
duration?: number;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""6idufv2q1c_add_credits_ratio_增加上传视频积分规则
|
||||
|
||||
Revision ID: a1b2c3d4e5f7
|
||||
Revises: 6idufv2q1c
|
||||
Create Date: 2026-07-01 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-01 的数据库迁移内容:
|
||||
1. 积分规则表增加传入视频计费字段(input_video_ratio, input_video_base_credits, input_video_per_second_credits)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '6idufv2q1c'
|
||||
down_revision: Union[str, None] = 'a1b2c3d4e5f7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-01 - 积分规则表增加传入视频计费字段
|
||||
# ========================================
|
||||
op.add_column('credit_ratios', sa.Column('input_video_ratio', sa.Float(), server_default='1.0', nullable=False))
|
||||
op.add_column('credit_ratios', sa.Column('input_video_base_credits', sa.Float(), server_default='0.0', nullable=False))
|
||||
op.add_column('credit_ratios', sa.Column('input_video_per_second_credits', sa.Float(), server_default='0.5', nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-01 - 积分规则表增加传入视频计费字段(回滚)
|
||||
# ========================================
|
||||
op.drop_column('credit_ratios', 'input_video_per_second_credits')
|
||||
op.drop_column('credit_ratios', 'input_video_base_credits')
|
||||
op.drop_column('credit_ratios', 'input_video_ratio')
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
|
||||
@@ -48,70 +48,40 @@ async def get_credit_ratios(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
|
||||
async def get_engine_with_ratios(gen_type: str, engines: list):
|
||||
for engine in engines:
|
||||
async def get_ratios_for_engine_type(gen_type: str, engine_ids: list):
|
||||
for engine_id in engine_ids:
|
||||
result = await db.execute(
|
||||
select(CreditRatio)
|
||||
.where(CreditRatio.gen_type == gen_type)
|
||||
.where(CreditRatio.model_config_id == engine.id)
|
||||
.where(CreditRatio.model_config_id == engine_id)
|
||||
)
|
||||
ratios = result.scalars().all()
|
||||
if ratios:
|
||||
ratios_out = [CreditRatioOut.model_validate(r) for r in ratios]
|
||||
engine_info = {
|
||||
"id": engine.id,
|
||||
"name": engine.name,
|
||||
"provider": engine.provider,
|
||||
"ratios": ratios_out,
|
||||
}
|
||||
if gen_type == "video":
|
||||
try:
|
||||
supported_ratios = json.loads(engine.supported_ratios) if engine.supported_ratios else []
|
||||
except Exception:
|
||||
supported_ratios = []
|
||||
try:
|
||||
supported_resolutions = json.loads(engine.supported_resolutions) if engine.supported_resolutions else []
|
||||
except Exception:
|
||||
supported_resolutions = []
|
||||
try:
|
||||
supported_durations = json.loads(engine.supported_durations) if engine.supported_durations else []
|
||||
except Exception:
|
||||
supported_durations = []
|
||||
engine_info.update({
|
||||
"supported_ratios": supported_ratios,
|
||||
"supported_resolutions": supported_resolutions,
|
||||
"supported_durations": supported_durations,
|
||||
"max_duration": engine.max_duration,
|
||||
"max_image_count": engine.max_image_count,
|
||||
"max_video_count": engine.max_video_count,
|
||||
})
|
||||
return engine_info
|
||||
return None
|
||||
|
||||
return [CreditRatioOut.model_validate(r) for r in ratios]
|
||||
return []
|
||||
|
||||
video_engines_result = await db.execute(
|
||||
select(VideoEngine)
|
||||
select(VideoEngine.id)
|
||||
.where(VideoEngine.is_active == True)
|
||||
.order_by(VideoEngine.priority.desc())
|
||||
)
|
||||
video_engines = video_engines_result.scalars().all()
|
||||
|
||||
video_engine_ids = video_engines_result.scalars().all()
|
||||
|
||||
image_engines_result = await db.execute(
|
||||
select(ImageEngine)
|
||||
select(ImageEngine.id)
|
||||
.where(ImageEngine.is_active == True)
|
||||
.order_by(ImageEngine.priority.desc())
|
||||
)
|
||||
image_engines = image_engines_result.scalars().all()
|
||||
|
||||
image_engine_ids = image_engines_result.scalars().all()
|
||||
|
||||
grouped = {}
|
||||
|
||||
video_data = await get_engine_with_ratios("video", video_engines)
|
||||
if video_data:
|
||||
grouped["video"] = video_data
|
||||
|
||||
image_data = await get_engine_with_ratios("image", image_engines)
|
||||
if image_data:
|
||||
grouped["image"] = image_data
|
||||
|
||||
|
||||
video_ratios = await get_ratios_for_engine_type("video", video_engine_ids)
|
||||
if video_ratios:
|
||||
grouped["video"] = video_ratios
|
||||
|
||||
image_ratios = await get_ratios_for_engine_type("image", image_engine_ids)
|
||||
if image_ratios:
|
||||
grouped["image"] = image_ratios
|
||||
|
||||
return grouped
|
||||
|
||||
@@ -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}
|
||||
@@ -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)}
|
||||
@@ -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)}",
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -22,3 +22,6 @@ class CreditRatio(Base, TimestampMixin):
|
||||
ratio: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
base_credits: Mapped[float] = mapped_column(Float, default=80.0)
|
||||
per_second_credits: Mapped[float] = mapped_column(Float, default=2.0)
|
||||
input_video_ratio: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
input_video_base_credits: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
input_video_per_second_credits: Mapped[float] = mapped_column(Float, default=0.5)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -43,6 +43,24 @@ class CreditRatioCreate(BaseModel):
|
||||
description="视频每秒积分。图片规则通常为 0",
|
||||
examples=[2.0],
|
||||
)
|
||||
input_video_ratio: float = Field(
|
||||
default=1.0,
|
||||
ge=0,
|
||||
description="传入视频积分倍率。视频生成时,用户上传参考视频的额外积分倍率",
|
||||
examples=[1.0],
|
||||
)
|
||||
input_video_base_credits: float = Field(
|
||||
default=0.0,
|
||||
ge=0,
|
||||
description="传入视频基础积分。视频生成时,用户上传参考视频的基础积分",
|
||||
examples=[0.0],
|
||||
)
|
||||
input_video_per_second_credits: float = Field(
|
||||
default=0.5,
|
||||
ge=0,
|
||||
description="传入视频每秒积分。视频生成时,用户上传参考视频每秒消耗的积分",
|
||||
examples=[0.5],
|
||||
)
|
||||
|
||||
|
||||
class CreditRatioOut(CreditRatioCreate):
|
||||
|
||||
@@ -14,6 +14,7 @@ class GenerationAIReference(BaseModel):
|
||||
"url": "https://example.com/reference.png",
|
||||
"type": "image",
|
||||
"name": "参考图.png",
|
||||
"duration": 5.0,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -33,6 +34,12 @@ class GenerationAIReference(BaseModel):
|
||||
description="参考素材名称,前端展示用,可为空",
|
||||
examples=["参考图.png"],
|
||||
)
|
||||
duration: float | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="视频素材时长(秒)。type=video 时使用,用于视频素材计费和时长校验",
|
||||
examples=[5.0],
|
||||
)
|
||||
|
||||
|
||||
class GenerationAITaskCreate(BaseModel):
|
||||
@@ -168,6 +175,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 +190,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 +229,7 @@ class GenerationAIEngineOptionsOut(BaseModel):
|
||||
},
|
||||
"default_size": "2K",
|
||||
"priority": 10,
|
||||
"max_image_count": 0,
|
||||
}
|
||||
],
|
||||
"video": [
|
||||
@@ -232,6 +243,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,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -65,6 +65,7 @@ async def calc_video_credits(
|
||||
duration: int,
|
||||
resolution: str,
|
||||
engine_id: str | None = None,
|
||||
input_video_duration: float | None = None,
|
||||
) -> float:
|
||||
"""Calculate video credits using CreditRatio table, with fallback to hardcoded.
|
||||
|
||||
@@ -72,8 +73,9 @@ async def calc_video_credits(
|
||||
1. gen_type=video + engine_id + resolution 精确规则;
|
||||
2. gen_type=video + resolution 下 base_credits/per_second_credits 最高规则;
|
||||
3. 原硬编码默认算法。
|
||||
|
||||
input_video_duration: 用户上传的参考视频总时长(秒),不为空时额外计费
|
||||
"""
|
||||
# 如果engine_id为空,默认查询权重最高的视频引擎积分规则
|
||||
if not engine_id:
|
||||
video_engines_result = await db.execute(
|
||||
select(VideoEngine.id)
|
||||
@@ -89,13 +91,21 @@ async def calc_video_credits(
|
||||
engine_id=engine_id,
|
||||
)
|
||||
if ratio:
|
||||
return round((ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio, 2)
|
||||
base_cost = (ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio
|
||||
if input_video_duration and input_video_duration > 0:
|
||||
input_video_cost = (
|
||||
ratio.input_video_base_credits + ratio.input_video_per_second_credits * input_video_duration
|
||||
) * ratio.input_video_ratio
|
||||
base_cost += input_video_cost
|
||||
return round(base_cost, 2)
|
||||
|
||||
# Fallback
|
||||
base = 60.0
|
||||
duration_cost = duration * 2.0
|
||||
multiplier = {"480p": 1, "1080p": 2, "720p": 1.5}.get(resolution, 1.0)
|
||||
return round((base + duration_cost) * multiplier, 2)
|
||||
total = (base + duration_cost) * multiplier
|
||||
if input_video_duration and input_video_duration > 0:
|
||||
total += input_video_duration * 0.5 * multiplier
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
def calc_credits(duration: int, resolution: str) -> float:
|
||||
|
||||
@@ -186,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()
|
||||
]
|
||||
@@ -200,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()
|
||||
]
|
||||
@@ -298,6 +301,18 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
||||
raise HTTPException(status_code=400, detail=f"视频时长不支持: {duration}")
|
||||
if engine.max_duration and duration > engine.max_duration:
|
||||
raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration} 秒")
|
||||
|
||||
input_video_duration = 0.0
|
||||
if refs:
|
||||
video_refs = [r for r in refs if r.get("type") == "video"]
|
||||
for ref in video_refs:
|
||||
ref_duration = float(ref.get("duration") or 0)
|
||||
if ref_duration < 2:
|
||||
raise HTTPException(status_code=400, detail=f"视频素材最短不能少于 2 秒")
|
||||
input_video_duration += ref_duration
|
||||
if input_video_duration > 15:
|
||||
raise HTTPException(status_code=400, detail=f"所有视频素材总时长不能超过 15 秒,当前 {input_video_duration:.1f} 秒")
|
||||
|
||||
media_billing = await charge_generation_media_by_params(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
@@ -306,6 +321,7 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
||||
duration=duration,
|
||||
resolution=resolution,
|
||||
engine_id=engine.id,
|
||||
input_video_duration=input_video_duration if input_video_duration > 0 else None,
|
||||
project_name="AI生成任务",
|
||||
description_prefix="AI创作-",
|
||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||
@@ -339,6 +355,51 @@ 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,
|
||||
@@ -407,7 +468,7 @@ 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,
|
||||
)
|
||||
@@ -608,7 +669,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,
|
||||
)
|
||||
|
||||
@@ -460,6 +460,7 @@ async def charge_generation_media_by_params(
|
||||
duration: int | None = None,
|
||||
resolution: str | None = None,
|
||||
engine_id: str | None = None,
|
||||
input_video_duration: float | None = None,
|
||||
project_name: str | None = None,
|
||||
description_prefix: str = "AI创作-",
|
||||
owner_type: str = OWNER_CHAT_GENERATION_TASK,
|
||||
@@ -522,7 +523,11 @@ async def charge_generation_media_by_params(
|
||||
)
|
||||
)
|
||||
elif gen_type == "video":
|
||||
amount = await calc_video_credits(db, duration or 5, resolution or "720p", engine_id=engine_id)
|
||||
amount = await calc_video_credits(
|
||||
db, duration or 5, resolution or "720p",
|
||||
engine_id=engine_id,
|
||||
input_video_duration=input_video_duration,
|
||||
)
|
||||
items.append(
|
||||
await deduct_credits_locked_once(
|
||||
db,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
+83
-83
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+442
File diff suppressed because one or more lines are too long
+442
File diff suppressed because one or more lines are too long
Vendored
+14
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 897 KiB |
Vendored
+2
-2
@@ -28,8 +28,8 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-Bc2N-TY0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BhPFzWLH.css">
|
||||
<script type="module" crossorigin src="/assets/index-DjAbNPLm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bi8mcSs8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -664,6 +664,7 @@ export async function getResourcesMaterialList(params: ResourcesMaterialListPara
|
||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||
return api.get(`/resources-material/list?${query.toString()}`);
|
||||
}
|
||||
|
||||
// home 获取各模块媒体
|
||||
export async function getmedit(limit:number): Promise<any> {
|
||||
return api.get(`/recent-generations?limit=${limit}&modules=project&modules=chat_ai&modules=hot_opening_replicate&modules=shot_replicate`);
|
||||
@@ -708,3 +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`);
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -57,10 +57,12 @@ const PreResultDisplay: React.FC<PreResultDisplayProps> = ({ preResult }) => {
|
||||
const isYes = value === 'YES';
|
||||
const isUnknown = value === 'UNKNOWN';
|
||||
let displayLabel, displayColor;
|
||||
if (isUnknown) {
|
||||
displayLabel = config.unknownLabel;
|
||||
displayColor = config.unknownColor;
|
||||
} else if (isYes) {
|
||||
// if (isUnknown) {
|
||||
// displayLabel = config.unknownLabel;
|
||||
// displayColor = config.unknownColor;
|
||||
// } else
|
||||
|
||||
if (isYes) {
|
||||
displayLabel = config.label;
|
||||
displayColor = config.yesColor;
|
||||
} else {
|
||||
|
||||
@@ -885,4 +885,28 @@ body {
|
||||
|
||||
.homepage-tabs .ant-tabs-ink-bar {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
}
|
||||
|
||||
.media-download-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.media-card:hover .media-download-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.media-preview-tip {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.media-card:hover .media-preview-tip {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -101,13 +101,6 @@ const ConsumePage: React.FC = () => {
|
||||
loadData(1, pageSize);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setAdvertiserId('');
|
||||
setConsumeDateRange(undefined);
|
||||
setCurrentPage(1);
|
||||
loadData(1, pageSize, null, '');
|
||||
};
|
||||
|
||||
const handleSync = () => {
|
||||
Modal.confirm({
|
||||
title: '拉取消耗数据',
|
||||
@@ -164,7 +157,9 @@ const ConsumePage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
|
||||
{searchParams.get('advertiserId') && (
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
|
||||
)}
|
||||
<DollarOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消耗记录</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
MenuFoldOutlined,
|
||||
SendOutlined,
|
||||
DeleteOutlined,
|
||||
CloseOutlined,
|
||||
RobotOutlined,
|
||||
LoadingOutlined,
|
||||
PictureOutlined,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
LayoutOutlined,
|
||||
ArrowUpOutlined,
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
|
||||
} from '@ant-design/icons';
|
||||
|
||||
@@ -62,6 +64,7 @@ interface MediaReference {
|
||||
name: string;
|
||||
type: 'image' | 'video';
|
||||
url: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -139,13 +142,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>('');
|
||||
@@ -202,6 +244,7 @@ const AIChatPage: React.FC = () => {
|
||||
const [attachmentPreviewUrl, setAttachmentPreviewUrl] = useState<string>('');
|
||||
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video'>('image');
|
||||
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
|
||||
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// 附件详情悬浮窗状态
|
||||
const [attachmentPopupVisible, setAttachmentPopupVisible] = useState<boolean>(false);
|
||||
@@ -239,6 +282,9 @@ const AIChatPage: React.FC = () => {
|
||||
perSecondCredits: 2,
|
||||
baseCredits: 60,
|
||||
ratio: 1,
|
||||
inputVideoRatio: 1,
|
||||
inputVideoBaseCredits: 0,
|
||||
inputVideoPerSecondCredits: 0.5,
|
||||
};
|
||||
if (videoResolution === '1080p') {
|
||||
config.ratio = 2;
|
||||
@@ -263,13 +309,21 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 根据配置计算积分
|
||||
if (mediaType === 'video') {
|
||||
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio
|
||||
return Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
|
||||
let total = Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
|
||||
// 传入视频积分
|
||||
const inputVideoDuration = currentMedia
|
||||
.filter((m) => m.type === 'video')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (inputVideoDuration > 0) {
|
||||
const inputVideoCost = Math.round(
|
||||
((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1)
|
||||
);
|
||||
total += inputVideoCost;
|
||||
}
|
||||
return total;
|
||||
} else {
|
||||
// 图片:baseCredits × ratio
|
||||
return config.baseCredits * config.ratio;
|
||||
@@ -393,44 +447,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 +474,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(() => { });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -760,6 +849,22 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
resolve(video.duration);
|
||||
};
|
||||
video.onerror = () => {
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
reject(new Error('无法获取视频时长'));
|
||||
};
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
// 验证文件类型
|
||||
const isImage = file.type.startsWith('image/');
|
||||
@@ -770,6 +875,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,20 +894,42 @@ 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;
|
||||
}
|
||||
|
||||
// 获取视频时长并验证
|
||||
let videoDuration = 0;
|
||||
if (isVideo) {
|
||||
try {
|
||||
videoDuration = await getVideoDuration(file);
|
||||
if (videoDuration < 2) {
|
||||
message.error('视频素材最短不能少于 2 秒');
|
||||
return false;
|
||||
}
|
||||
const existingVideoDuration = currentMedia
|
||||
.filter((m) => m.type === 'video')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (existingVideoDuration + videoDuration > 15) {
|
||||
message.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
message.error('无法获取视频时长');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 设置上传状态
|
||||
setUploading(true);
|
||||
|
||||
@@ -799,11 +938,16 @@ 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: '',
|
||||
...(isVideo && { duration: videoDuration }),
|
||||
}];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
message.success(`${isImage ? '图片' : '视频'}上传成功`);
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
@@ -817,7 +961,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 +974,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 +1164,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 +1174,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 +1182,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 +1328,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 +1398,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 +1409,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 +1422,9 @@ const AIChatPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 12 }} />
|
||||
删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 文本内容 */}
|
||||
@@ -1261,7 +1512,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: '#ef4444' }}>生成失败(积分已退)</span>
|
||||
{msg.errorMessage && (
|
||||
<span style={{ fontSize: 13, color: '#ef4444', textAlign: 'center', padding: '0 8px', lineHeight: 1.5 }}>{msg.errorMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -1300,11 +1554,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>
|
||||
|
||||
|
||||
@@ -1367,26 +1616,25 @@ const AIChatPage: React.FC = () => {
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: '#ff4d4f',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
color: '#999',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#ff7875';
|
||||
e.currentTarget.style.transform = 'scale(1.1)';
|
||||
e.currentTarget.style.background = '#f5f5f5';
|
||||
e.currentTarget.style.color = '#333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#ff4d4f';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#999';
|
||||
}}
|
||||
>
|
||||
×
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
{gen_list.find((msg: any) => msg.id === attachmentPopupMessageId)?.mediaReferences?.map((ref: any, idx: number) => (
|
||||
@@ -1444,19 +1692,35 @@ 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}`}
|
||||
alt={media.name}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8 }}
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewType('image');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8 }}
|
||||
muted
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewType('video');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 500 }}>
|
||||
{media.label}
|
||||
</span>
|
||||
{/* 删除已上传媒体按钮 */}
|
||||
<button
|
||||
onClick={() => handleRemoveMedia(idx)}
|
||||
@@ -1477,7 +1741,7 @@ const AIChatPage: React.FC = () => {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
<DeleteOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -1495,14 +1759,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 +1807,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 +1827,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"
|
||||
@@ -2389,7 +2712,25 @@ const AIChatPage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
onCancel={() => setAttachmentPreviewVisible(false)}
|
||||
onCancel={() => {
|
||||
// 关闭前强制停止视频播放,避免关闭后仍有声音
|
||||
if (attachmentPreviewVideoRef.current) {
|
||||
const v = attachmentPreviewVideoRef.current;
|
||||
v.pause();
|
||||
v.muted = true;
|
||||
v.removeAttribute('src');
|
||||
v.load();
|
||||
}
|
||||
setAttachmentPreviewVisible(false);
|
||||
}}
|
||||
afterClose={() => {
|
||||
// Modal 完全关闭后再次确保视频被停止
|
||||
if (attachmentPreviewVideoRef.current) {
|
||||
const v = attachmentPreviewVideoRef.current;
|
||||
v.pause();
|
||||
v.muted = true;
|
||||
}
|
||||
}}
|
||||
width={800}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
@@ -2432,6 +2773,7 @@ const AIChatPage: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={attachmentPreviewVideoRef}
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { getResourcesMaterialList, getPreTestList } from '../api';
|
||||
import { getResourcesMaterialList, getPreTestList, submitPreTest, getDefaultPreTest } from '../api';
|
||||
import PreResultDisplay from '../components/PreResultDisplay';
|
||||
|
||||
// 格式化时间 2026-06-12T03:47:28.542988Z -> 2026-06-12 03:47:28
|
||||
@@ -90,6 +90,9 @@ const MaterialListPage: React.FC = () => {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [pushModalOpen, setPushModalOpen] = useState(false);
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
||||
const [pushPreTestTemplate, setPushPreTestTemplate] = useState<string>('');
|
||||
const [pushTemplates, setPushTemplates] = useState<any[]>([]);
|
||||
const [pushTemplatesLoading, setPushTemplatesLoading] = useState(false);
|
||||
@@ -181,7 +184,11 @@ const MaterialListPage: React.FC = () => {
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => window.open(buildUrl(url), '_blank')}
|
||||
onClick={() => {
|
||||
setPreviewUrl(buildUrl(url));
|
||||
setPreviewType(record.resourceType === 'video' ? 'video' : 'image');
|
||||
setPreviewModalOpen(true);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
查看
|
||||
@@ -318,14 +325,17 @@ const MaterialListPage: React.FC = () => {
|
||||
}
|
||||
setPushTemplatesLoading(true);
|
||||
try {
|
||||
const res = await getPreTestList({ page: 1, pageSize: 100 });
|
||||
setPushTemplates(res.data || []);
|
||||
const [listRes, defaultRes] = await Promise.all([
|
||||
getPreTestList({ page: 1, pageSize: 100 }),
|
||||
getDefaultPreTest(),
|
||||
]);
|
||||
setPushTemplates(listRes.data || []);
|
||||
setPushPreTestTemplate(defaultRes.data?.id || '');
|
||||
} catch (error) {
|
||||
message.error('获取前测模板列表失败');
|
||||
} finally {
|
||||
setPushTemplatesLoading(false);
|
||||
}
|
||||
setPushPreTestTemplate('');
|
||||
setPushModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -338,9 +348,18 @@ const MaterialListPage: React.FC = () => {
|
||||
message.warning('请选择前测模板');
|
||||
return;
|
||||
}
|
||||
message.success('推送素材成功');
|
||||
setPushModalOpen(false);
|
||||
setSelectedRows(new Set());
|
||||
try {
|
||||
const materialIds = Array.from(selectedRows);
|
||||
await submitPreTest({
|
||||
resources_material_ids: materialIds,
|
||||
pre_test_template_id: pushPreTestTemplate,
|
||||
});
|
||||
message.success('推送素材成功');
|
||||
setPushModalOpen(false);
|
||||
setSelectedRows(new Set());
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '推送素材失败');
|
||||
}
|
||||
};
|
||||
|
||||
const selectedRecords = records.filter(record => selectedRows.has(record.id));
|
||||
@@ -403,7 +422,7 @@ const MaterialListPage: React.FC = () => {
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
{/* <div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={pushTemplatesLoading}
|
||||
@@ -420,7 +439,7 @@ const MaterialListPage: React.FC = () => {
|
||||
>
|
||||
推送前测 ({selectedRows.size})
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
|
||||
<Table
|
||||
@@ -431,13 +450,13 @@ const MaterialListPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
// rowSelection={{
|
||||
// type: 'checkbox',
|
||||
// selectedRowKeys: Array.from(selectedRows),
|
||||
// onChange: (keys) => {
|
||||
// setSelectedRows(new Set(keys as string[]));
|
||||
// },
|
||||
// }}
|
||||
rowSelection={{
|
||||
type: 'checkbox',
|
||||
selectedRowKeys: Array.from(selectedRows),
|
||||
onChange: (keys) => {
|
||||
setSelectedRows(new Set(keys as string[]));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
@@ -505,6 +524,32 @@ const MaterialListPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="预览"
|
||||
open={previewModalOpen}
|
||||
onCancel={() => setPreviewModalOpen(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
style={{ borderRadius: 16 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
{previewType === 'video' ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 8 }}
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="预览"
|
||||
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -163,7 +163,6 @@ const PreTest: React.FC = () => {
|
||||
const loadRecords = async (params?: { page?: number; pageSize?: number; platform?: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log(params);
|
||||
const page = params?.page ?? currentPage;
|
||||
const size = params?.pageSize ?? pageSize;
|
||||
const platform = params?.platform ?? searchPlatform;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,6 +32,7 @@ interface AppState {
|
||||
};
|
||||
enginesele: any;
|
||||
inputValue: string;
|
||||
currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string; duration?: number }[];
|
||||
|
||||
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; duration?: number }[]) => 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: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user